feat: add flowstudio-power-automate-debug and flowstudio-power-automate-build skills (#899)

* feat: add flowstudio-power-automate-debug and flowstudio-power-automate-build skills

Two companion skills for the FlowStudio Power Automate MCP server:

- flowstudio-power-automate-debug: Debug workflow for failed Power Automate cloud flow runs
- flowstudio-power-automate-build: Build & deploy flows from natural language descriptions

Both require a FlowStudio MCP subscription: https://flowstudio.app
These complement the existing flowstudio-power-automate-mcp skill (merged in PR #896).

* fix: address all review comments — README, cross-refs, response shapes, step numbering

- Add skills to docs/README.skills.md (fixes validate-readme CI check)
- Update cross-skill references to use flowstudio- prefix (#1, #4, #7, #9)
- Fix get_live_flow_run_action_outputs: returns array, index [0] (#2, #3)
- Renumber Step 6→5, Step 7→6 — remove gap in build workflow (#8)
- Fix connectionName note: it's the key, not the GUID (#10)
- Remove invalid arrow function from Filter array expression (#11)

* feat: add flowstudio-power-automate plugin bundling all 3 skills

Plugin bundles:
- flowstudio-power-automate-mcp (core connection & CRUD)
- flowstudio-power-automate-debug (debug failed runs)
- flowstudio-power-automate-build (build & deploy flows)

Install: copilot plugin install flowstudio-power-automate@awesome-copilot

Per @aaronpowell's suggestion in review.
This commit is contained in:
Catherine Han
2026-03-09 09:58:31 +11:00
committed by GitHub
parent a61d7bf9c1
commit 0a16fe4285
15 changed files with 3560 additions and 0 deletions

View File

@@ -0,0 +1,542 @@
# FlowStudio MCP — Action Patterns: Connectors
SharePoint, Outlook, Teams, and Approvals connector action patterns.
> All examples assume `"runAfter"` is set appropriately.
> Replace `<connectionName>` with the **key** you used in `connectionReferences`
> (e.g. `shared_sharepointonline`, `shared_teams`). This is NOT the connection
> GUID — it is the logical reference name that links the action to its entry in
> the `connectionReferences` map.
---
## SharePoint
### SharePoint — Get Items
```json
"Get_SP_Items": {
"type": "OpenApiConnection",
"runAfter": {},
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline",
"connectionName": "<connectionName>",
"operationId": "GetItems"
},
"parameters": {
"dataset": "https://mytenant.sharepoint.com/sites/mysite",
"table": "MyList",
"$filter": "Status eq 'Active'",
"$top": 500
}
}
}
```
Result reference: `@outputs('Get_SP_Items')?['body/value']`
> **Dynamic OData filter with string interpolation**: inject a runtime value
> directly into the `$filter` string using `@{...}` syntax:
> ```
> "$filter": "Title eq '@{outputs('ConfirmationCode')}'"
> ```
> Note the single-quotes inside double-quotes — correct OData string literal
> syntax. Avoids a separate variable action.
> **Pagination for large lists**: by default, GetItems stops at `$top`. To auto-paginate
> beyond that, enable the pagination policy on the action. In the flow definition this
> appears as:
> ```json
> "paginationPolicy": { "minimumItemCount": 10000 }
> ```
> Set `minimumItemCount` to the maximum number of items you expect. The connector will
> keep fetching pages until that count is reached or the list is exhausted. Without this,
> flows silently return a capped result on lists with >5,000 items.
---
### SharePoint — Get Item (Single Row by ID)
```json
"Get_SP_Item": {
"type": "OpenApiConnection",
"runAfter": {},
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline",
"connectionName": "<connectionName>",
"operationId": "GetItem"
},
"parameters": {
"dataset": "https://mytenant.sharepoint.com/sites/mysite",
"table": "MyList",
"id": "@triggerBody()?['ID']"
}
}
}
```
Result reference: `@body('Get_SP_Item')?['FieldName']`
> Use `GetItem` (not `GetItems` with a filter) when you already have the ID.
> Re-fetching after a trigger gives you the **current** row state, not the
> snapshot captured at trigger time — important if another process may have
> modified the item since the flow started.
---
### SharePoint — Create Item
```json
"Create_SP_Item": {
"type": "OpenApiConnection",
"runAfter": {},
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline",
"connectionName": "<connectionName>",
"operationId": "PostItem"
},
"parameters": {
"dataset": "https://mytenant.sharepoint.com/sites/mysite",
"table": "MyList",
"item/Title": "@variables('myTitle')",
"item/Status": "Active"
}
}
}
```
---
### SharePoint — Update Item
```json
"Update_SP_Item": {
"type": "OpenApiConnection",
"runAfter": {},
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline",
"connectionName": "<connectionName>",
"operationId": "PatchItem"
},
"parameters": {
"dataset": "https://mytenant.sharepoint.com/sites/mysite",
"table": "MyList",
"id": "@item()?['ID']",
"item/Status": "Processed"
}
}
}
```
---
### SharePoint — File Upsert (Create or Overwrite in Document Library)
SharePoint's `CreateFile` fails if the file already exists. To upsert (create or overwrite)
without a prior existence check, use `GetFileMetadataByPath` on **both Succeeded and Failed**
from `CreateFile` — if create failed because the file exists, the metadata call still
returns its ID, which `UpdateFile` can then overwrite:
```json
"Create_File": {
"type": "OpenApiConnection",
"inputs": {
"host": { "apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline",
"connectionName": "<connectionName>", "operationId": "CreateFile" },
"parameters": {
"dataset": "https://mytenant.sharepoint.com/sites/mysite",
"folderPath": "/My Library/Subfolder",
"name": "@{variables('filename')}",
"body": "@outputs('Compose_File_Content')"
}
}
},
"Get_File_Metadata_By_Path": {
"type": "OpenApiConnection",
"runAfter": { "Create_File": ["Succeeded", "Failed"] },
"inputs": {
"host": { "apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline",
"connectionName": "<connectionName>", "operationId": "GetFileMetadataByPath" },
"parameters": {
"dataset": "https://mytenant.sharepoint.com/sites/mysite",
"path": "/My Library/Subfolder/@{variables('filename')}"
}
}
},
"Update_File": {
"type": "OpenApiConnection",
"runAfter": { "Get_File_Metadata_By_Path": ["Succeeded", "Skipped"] },
"inputs": {
"host": { "apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline",
"connectionName": "<connectionName>", "operationId": "UpdateFile" },
"parameters": {
"dataset": "https://mytenant.sharepoint.com/sites/mysite",
"id": "@outputs('Get_File_Metadata_By_Path')?['body/{Identifier}']",
"body": "@outputs('Compose_File_Content')"
}
}
}
```
> If `Create_File` succeeds, `Get_File_Metadata_By_Path` is `Skipped` and `Update_File`
> still fires (accepting `Skipped`), harmlessly overwriting the file just created.
> If `Create_File` fails (file exists), the metadata call retrieves the existing file's ID
> and `Update_File` overwrites it. Either way you end with the latest content.
>
> **Document library system properties** — when iterating a file library result (e.g.
> from `ListFolder` or `GetFilesV2`), use curly-brace property names to access
> SharePoint's built-in file metadata. These are different from list field names:
> ```
> @item()?['{Name}'] — filename without path (e.g. "report.csv")
> @item()?['{FilenameWithExtension}'] — same as {Name} in most connectors
> @item()?['{Identifier}'] — internal file ID for use in UpdateFile/DeleteFile
> @item()?['{FullPath}'] — full server-relative path
> @item()?['{IsFolder}'] — boolean, true for folder entries
> ```
---
### SharePoint — GetItemChanges Column Gate
When a SharePoint "item modified" trigger fires, it doesn't tell you WHICH
column changed. Use `GetItemChanges` to get per-column change flags, then gate
downstream logic on specific columns:
```json
"Get_Changes": {
"type": "OpenApiConnection",
"runAfter": {},
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline",
"connectionName": "<connectionName>",
"operationId": "GetItemChanges"
},
"parameters": {
"dataset": "https://mytenant.sharepoint.com/sites/mysite",
"table": "<list-guid>",
"id": "@triggerBody()?['ID']",
"since": "@triggerBody()?['Modified']",
"includeDrafts": false
}
}
}
```
Gate on a specific column:
```json
"expression": {
"and": [{
"equals": [
"@body('Get_Changes')?['Column']?['hasChanged']",
true
]
}]
}
```
> **New-item detection:** On the very first modification (version 1.0),
> `GetItemChanges` may report no prior version. Check
> `@equals(triggerBody()?['OData__UIVersionString'], '1.0')` to detect
> newly created items and skip change-gate logic for those.
---
### SharePoint — REST MERGE via HttpRequest
For cross-list updates or advanced operations not supported by the standard
Update Item connector (e.g., updating a list in a different site), use the
SharePoint REST API via the `HttpRequest` operation:
```json
"Update_Cross_List_Item": {
"type": "OpenApiConnection",
"runAfter": {},
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline",
"connectionName": "<connectionName>",
"operationId": "HttpRequest"
},
"parameters": {
"dataset": "https://mytenant.sharepoint.com/sites/target-site",
"parameters/method": "POST",
"parameters/uri": "/_api/web/lists(guid'<list-guid>')/items(@{variables('ItemId')})",
"parameters/headers": {
"Accept": "application/json;odata=nometadata",
"Content-Type": "application/json;odata=nometadata",
"X-HTTP-Method": "MERGE",
"IF-MATCH": "*"
},
"parameters/body": "{ \"Title\": \"@{variables('NewTitle')}\", \"Status\": \"@{variables('NewStatus')}\" }"
}
}
}
```
> **Key headers:**
> - `X-HTTP-Method: MERGE` — tells SharePoint to do a partial update (PATCH semantics)
> - `IF-MATCH: *` — overwrites regardless of current ETag (no conflict check)
>
> The `HttpRequest` operation reuses the existing SharePoint connection — no extra
> authentication needed. Use this when the standard Update Item connector can't
> reach the target list (different site collection, or you need raw REST control).
---
### SharePoint — File as JSON Database (Read + Parse)
Use a SharePoint document library JSON file as a queryable "database" of
last-known-state records. A separate process (e.g., Power BI dataflow) maintains
the file; the flow downloads and filters it for before/after comparisons.
```json
"Get_File": {
"type": "OpenApiConnection",
"runAfter": {},
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline",
"connectionName": "<connectionName>",
"operationId": "GetFileContent"
},
"parameters": {
"dataset": "https://mytenant.sharepoint.com/sites/mysite",
"id": "%252fShared%2bDocuments%252fdata.json",
"inferContentType": false
}
}
},
"Parse_JSON_File": {
"type": "Compose",
"runAfter": { "Get_File": ["Succeeded"] },
"inputs": "@json(decodeBase64(body('Get_File')?['$content']))"
},
"Find_Record": {
"type": "Query",
"runAfter": { "Parse_JSON_File": ["Succeeded"] },
"inputs": {
"from": "@outputs('Parse_JSON_File')",
"where": "@equals(item()?['id'], variables('RecordId'))"
}
}
```
> **Decode chain:** `GetFileContent` returns base64-encoded content in
> `body(...)?['$content']`. Apply `decodeBase64()` then `json()` to get a
> usable array. `Filter Array` then acts as a WHERE clause.
>
> **When to use:** When you need a lightweight "before" snapshot to detect field
> changes from a webhook payload (the "after" state). Simpler than maintaining
> a full SharePoint list mirror — works well for up to ~10K records.
>
> **File path encoding:** In the `id` parameter, SharePoint URL-encodes paths
> twice. Spaces become `%2b` (plus sign), slashes become `%252f`.
---
## Outlook
### Outlook — Send Email
```json
"Send_Email": {
"type": "OpenApiConnection",
"runAfter": {},
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_office365",
"connectionName": "<connectionName>",
"operationId": "SendEmailV2"
},
"parameters": {
"emailMessage/To": "recipient@contoso.com",
"emailMessage/Subject": "Automated notification",
"emailMessage/Body": "<p>@{outputs('Compose_Message')}</p>",
"emailMessage/IsHtml": true
}
}
}
```
---
### Outlook — Get Emails (Read Template from Folder)
```json
"Get_Email_Template": {
"type": "OpenApiConnection",
"runAfter": {},
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_office365",
"connectionName": "<connectionName>",
"operationId": "GetEmailsV3"
},
"parameters": {
"folderPath": "Id::<outlook-folder-id>",
"fetchOnlyUnread": false,
"includeAttachments": false,
"top": 1,
"importance": "Any",
"fetchOnlyWithAttachment": false,
"subjectFilter": "My Email Template Subject"
}
}
}
```
Access subject and body:
```
@first(outputs('Get_Email_Template')?['body/value'])?['subject']
@first(outputs('Get_Email_Template')?['body/value'])?['body']
```
> **Outlook-as-CMS pattern**: store a template email in a dedicated Outlook folder.
> Set `fetchOnlyUnread: false` so the template persists after first use.
> Non-technical users can update subject and body by editing that email —
> no flow changes required. Pass subject and body directly into `SendEmailV2`.
>
> To get a folder ID: in Outlook on the web, right-click the folder → open in
> new tab — the folder GUID is in the URL. Prefix it with `Id::` in `folderPath`.
---
## Teams
### Teams — Post Message
```json
"Post_Teams_Message": {
"type": "OpenApiConnection",
"runAfter": {},
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_teams",
"connectionName": "<connectionName>",
"operationId": "PostMessageToConversation"
},
"parameters": {
"poster": "Flow bot",
"location": "Channel",
"body/recipient": {
"groupId": "<team-id>",
"channelId": "<channel-id>"
},
"body/messageBody": "@outputs('Compose_Message')"
}
}
}
```
#### Variant: Group Chat (1:1 or Multi-Person)
To post to a group chat instead of a channel, use `"location": "Group chat"` with
a thread ID as the recipient:
```json
"Post_To_Group_Chat": {
"type": "OpenApiConnection",
"runAfter": {},
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_teams",
"connectionName": "<connectionName>",
"operationId": "PostMessageToConversation"
},
"parameters": {
"poster": "Flow bot",
"location": "Group chat",
"body/recipient": "19:<thread-hash>@thread.v2",
"body/messageBody": "@outputs('Compose_Message')"
}
}
}
```
For 1:1 ("Chat with Flow bot"), use `"location": "Chat with Flow bot"` and set
`body/recipient` to the user's email address.
> **Active-user gate:** When sending notifications in a loop, check the recipient's
> Azure AD account is enabled before posting — avoids failed deliveries to departed
> staff:
> ```json
> "Check_User_Active": {
> "type": "OpenApiConnection",
> "inputs": {
> "host": { "apiId": "/providers/Microsoft.PowerApps/apis/shared_office365users",
> "operationId": "UserProfile_V2" },
> "parameters": { "id": "@{item()?['Email']}" }
> }
> }
> ```
> Then gate: `@equals(body('Check_User_Active')?['accountEnabled'], true)`
---
## Approvals
### Split Approval (Create → Wait)
The standard "Start and wait for an approval" is a single blocking action.
For more control (e.g., posting the approval link in Teams, or adding a timeout
scope), split it into two actions: `CreateAnApproval` (fire-and-forget) then
`WaitForAnApproval` (webhook pause).
```json
"Create_Approval": {
"type": "OpenApiConnection",
"runAfter": {},
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_approvals",
"connectionName": "<connectionName>",
"operationId": "CreateAnApproval"
},
"parameters": {
"approvalType": "CustomResponse/Result",
"ApprovalCreationInput/title": "Review: @{variables('ItemTitle')}",
"ApprovalCreationInput/assignedTo": "approver@contoso.com",
"ApprovalCreationInput/details": "Please review and select an option.",
"ApprovalCreationInput/responseOptions": ["Approve", "Reject", "Defer"],
"ApprovalCreationInput/enableNotifications": true,
"ApprovalCreationInput/enableReassignment": true
}
}
},
"Wait_For_Approval": {
"type": "OpenApiConnectionWebhook",
"runAfter": { "Create_Approval": ["Succeeded"] },
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_approvals",
"connectionName": "<connectionName>",
"operationId": "WaitForAnApproval"
},
"parameters": {
"approvalName": "@body('Create_Approval')?['name']"
}
}
}
```
> **`approvalType` options:**
> - `"Approve/Reject - First to respond"` — binary, first responder wins
> - `"Approve/Reject - Everyone must approve"` — requires all assignees
> - `"CustomResponse/Result"` — define your own response buttons
>
> After `Wait_For_Approval`, read the outcome:
> ```
> @body('Wait_For_Approval')?['outcome'] → "Approve", "Reject", or custom
> @body('Wait_For_Approval')?['responses'][0]?['responder']?['displayName']
> @body('Wait_For_Approval')?['responses'][0]?['comments']
> ```
>
> The split pattern lets you insert actions between create and wait — e.g.,
> posting the approval link to Teams, starting a timeout scope, or logging
> the pending approval to a tracking list.

View File

@@ -0,0 +1,542 @@
# FlowStudio MCP — Action Patterns: Core
Variables, control flow, and expression patterns for Power Automate flow definitions.
> All examples assume `"runAfter"` is set appropriately.
> Replace `<connectionName>` with the **key** you used in your `connectionReferences` map
> (e.g. `shared_teams`, `shared_office365`) — NOT the connection GUID.
---
## Data & Variables
### Compose (Store a Value)
```json
"Compose_My_Value": {
"type": "Compose",
"runAfter": {},
"inputs": "@variables('myVar')"
}
```
Reference: `@outputs('Compose_My_Value')`
---
### Initialize Variable
```json
"Init_Counter": {
"type": "InitializeVariable",
"runAfter": {},
"inputs": {
"variables": [{
"name": "counter",
"type": "Integer",
"value": 0
}]
}
}
```
Types: `"Integer"`, `"Float"`, `"Boolean"`, `"String"`, `"Array"`, `"Object"`
---
### Set Variable
```json
"Set_Counter": {
"type": "SetVariable",
"runAfter": {},
"inputs": {
"name": "counter",
"value": "@add(variables('counter'), 1)"
}
}
```
---
### Append to Array Variable
```json
"Collect_Item": {
"type": "AppendToArrayVariable",
"runAfter": {},
"inputs": {
"name": "resultArray",
"value": "@item()"
}
}
```
---
### Increment Variable
```json
"Increment_Counter": {
"type": "IncrementVariable",
"runAfter": {},
"inputs": {
"name": "counter",
"value": 1
}
}
```
> Use `IncrementVariable` (not `SetVariable` with `add()`) for counters inside loops —
> it is atomic and avoids expression errors when the variable is used elsewhere in the
> same iteration. `value` can be any integer or expression, e.g. `@mul(item()?['Interval'], 60)`
> to advance a Unix timestamp cursor by N minutes.
---
## Control Flow
### Condition (If/Else)
```json
"Check_Status": {
"type": "If",
"runAfter": {},
"expression": {
"and": [{ "equals": ["@item()?['Status']", "Active"] }]
},
"actions": {
"Handle_Active": {
"type": "Compose",
"runAfter": {},
"inputs": "Active user: @{item()?['Name']}"
}
},
"else": {
"actions": {
"Handle_Inactive": {
"type": "Compose",
"runAfter": {},
"inputs": "Inactive user"
}
}
}
}
```
Comparison operators: `equals`, `not`, `greater`, `greaterOrEquals`, `less`, `lessOrEquals`, `contains`
Logical: `and: [...]`, `or: [...]`
---
### Switch
```json
"Route_By_Type": {
"type": "Switch",
"runAfter": {},
"expression": "@triggerBody()?['type']",
"cases": {
"Case_Email": {
"case": "email",
"actions": { "Process_Email": { "type": "Compose", "runAfter": {}, "inputs": "email" } }
},
"Case_Teams": {
"case": "teams",
"actions": { "Process_Teams": { "type": "Compose", "runAfter": {}, "inputs": "teams" } }
}
},
"default": {
"actions": { "Unknown_Type": { "type": "Compose", "runAfter": {}, "inputs": "unknown" } }
}
}
```
---
### Scope (Grouping / Try-Catch)
Wrap related actions in a Scope to give them a shared name, collapse them in the
designer, and — most importantly — handle their errors as a unit.
```json
"Scope_Get_Customer": {
"type": "Scope",
"runAfter": {},
"actions": {
"HTTP_Get_Customer": {
"type": "Http",
"runAfter": {},
"inputs": {
"method": "GET",
"uri": "https://api.example.com/customers/@{variables('customerId')}"
}
},
"Compose_Email": {
"type": "Compose",
"runAfter": { "HTTP_Get_Customer": ["Succeeded"] },
"inputs": "@outputs('HTTP_Get_Customer')?['body/email']"
}
}
},
"Handle_Scope_Error": {
"type": "Compose",
"runAfter": { "Scope_Get_Customer": ["Failed", "TimedOut"] },
"inputs": "Scope failed: @{result('Scope_Get_Customer')?[0]?['error']?['message']}"
}
```
> Reference scope results: `@result('Scope_Get_Customer')` returns an array of action
> outcomes. Use `runAfter: {"MyScope": ["Failed", "TimedOut"]}` on a follow-up action
> to create try/catch semantics without a Terminate.
---
### Foreach (Sequential)
```json
"Process_Each_Item": {
"type": "Foreach",
"runAfter": {},
"foreach": "@outputs('Get_Items')?['body/value']",
"operationOptions": "Sequential",
"actions": {
"Handle_Item": {
"type": "Compose",
"runAfter": {},
"inputs": "@item()?['Title']"
}
}
}
```
> Always include `"operationOptions": "Sequential"` unless parallel is intentional.
---
### Foreach (Parallel with Concurrency Limit)
```json
"Process_Each_Item_Parallel": {
"type": "Foreach",
"runAfter": {},
"foreach": "@body('Get_SP_Items')?['value']",
"runtimeConfiguration": {
"concurrency": {
"repetitions": 20
}
},
"actions": {
"HTTP_Upsert": {
"type": "Http",
"runAfter": {},
"inputs": {
"method": "POST",
"uri": "https://api.example.com/contacts/@{item()?['Email']}"
}
}
}
}
```
> Set `repetitions` to control how many items are processed simultaneously.
> Practical values: `510` for external API calls (respect rate limits),
> `2050` for internal/fast operations.
> Omit `runtimeConfiguration.concurrency` entirely for the platform default
> (currently 50). Do NOT use `"operationOptions": "Sequential"` and concurrency together.
---
### Wait (Delay)
```json
"Delay_10_Minutes": {
"type": "Wait",
"runAfter": {},
"inputs": {
"interval": {
"count": 10,
"unit": "Minute"
}
}
}
```
Valid `unit` values: `"Second"`, `"Minute"`, `"Hour"`, `"Day"`
> Use a Delay + re-fetch as a deduplication guard: wait for any competing process
> to complete, then re-read the record before acting. This avoids double-processing
> when multiple triggers or manual edits can race on the same item.
---
### Terminate (Success or Failure)
```json
"Terminate_Success": {
"type": "Terminate",
"runAfter": {},
"inputs": {
"runStatus": "Succeeded"
}
},
"Terminate_Failure": {
"type": "Terminate",
"runAfter": { "Risky_Action": ["Failed"] },
"inputs": {
"runStatus": "Failed",
"runError": {
"code": "StepFailed",
"message": "@{outputs('Get_Error_Message')}"
}
}
}
```
---
### Do Until (Loop Until Condition)
Repeats a block of actions until an exit condition becomes true.
Use when the number of iterations is not known upfront (e.g. paginating an API,
walking a time range, polling until a status changes).
```json
"Do_Until_Done": {
"type": "Until",
"runAfter": {},
"expression": "@greaterOrEquals(variables('cursor'), variables('endValue'))",
"limit": {
"count": 5000,
"timeout": "PT5H"
},
"actions": {
"Do_Work": {
"type": "Compose",
"runAfter": {},
"inputs": "@variables('cursor')"
},
"Advance_Cursor": {
"type": "IncrementVariable",
"runAfter": { "Do_Work": ["Succeeded"] },
"inputs": {
"name": "cursor",
"value": 1
}
}
}
}
```
> Always set `limit.count` and `limit.timeout` explicitly — the platform defaults are
> low (60 iterations, 1 hour). For time-range walkers use `limit.count: 5000` and
> `limit.timeout: "PT5H"` (ISO 8601 duration).
>
> The exit condition is evaluated **before** each iteration. Initialise your cursor
> variable before the loop so the condition can evaluate correctly on the first pass.
---
### Async Polling with RequestId Correlation
When an API starts a long-running job asynchronously (e.g. Power BI dataset refresh,
report generation, batch export), the trigger call returns a request ID. Capture it
from the **response header**, then poll a status endpoint filtering by that exact ID:
```json
"Start_Job": {
"type": "Http",
"inputs": { "method": "POST", "uri": "https://api.example.com/jobs" }
},
"Capture_Request_ID": {
"type": "Compose",
"runAfter": { "Start_Job": ["Succeeded"] },
"inputs": "@outputs('Start_Job')?['headers/X-Request-Id']"
},
"Initialize_Status": {
"type": "InitializeVariable",
"inputs": { "variables": [{ "name": "jobStatus", "type": "String", "value": "Running" }] }
},
"Poll_Until_Done": {
"type": "Until",
"expression": "@not(equals(variables('jobStatus'), 'Running'))",
"limit": { "count": 60, "timeout": "PT30M" },
"actions": {
"Delay": { "type": "Wait", "inputs": { "interval": { "count": 20, "unit": "Second" } } },
"Get_History": {
"type": "Http",
"runAfter": { "Delay": ["Succeeded"] },
"inputs": { "method": "GET", "uri": "https://api.example.com/jobs/history" }
},
"Filter_This_Job": {
"type": "Query",
"runAfter": { "Get_History": ["Succeeded"] },
"inputs": {
"from": "@outputs('Get_History')?['body/items']",
"where": "@equals(item()?['requestId'], outputs('Capture_Request_ID'))"
}
},
"Set_Status": {
"type": "SetVariable",
"runAfter": { "Filter_This_Job": ["Succeeded"] },
"inputs": {
"name": "jobStatus",
"value": "@first(body('Filter_This_Job'))?['status']"
}
}
}
},
"Handle_Failure": {
"type": "If",
"runAfter": { "Poll_Until_Done": ["Succeeded"] },
"expression": { "equals": ["@variables('jobStatus')", "Failed"] },
"actions": { "Terminate_Failed": { "type": "Terminate", "inputs": { "runStatus": "Failed" } } },
"else": { "actions": {} }
}
```
Access response headers: `@outputs('Start_Job')?['headers/X-Request-Id']`
> **Status variable initialisation**: set a sentinel value (`"Running"`, `"Unknown"`) before
> the loop. The exit condition tests for any value other than the sentinel.
> This way an empty poll result (job not yet in history) leaves the variable unchanged
> and the loop continues — it doesn't accidentally exit on null.
>
> **Filter before extracting**: always `Filter Array` the history to your specific
> request ID before calling `first()`. History endpoints return all jobs; without
> filtering, status from a different concurrent job can corrupt your poll.
---
### runAfter Fallback (Failed → Alternative Action)
Route to a fallback action when a primary action fails — without a Condition block.
Simply set `runAfter` on the fallback to accept `["Failed"]` from the primary:
```json
"HTTP_Get_Hi_Res": {
"type": "Http",
"runAfter": {},
"inputs": { "method": "GET", "uri": "https://api.example.com/data?resolution=hi-res" }
},
"HTTP_Get_Low_Res": {
"type": "Http",
"runAfter": { "HTTP_Get_Hi_Res": ["Failed"] },
"inputs": { "method": "GET", "uri": "https://api.example.com/data?resolution=low-res" }
}
```
> Actions that follow can use `runAfter` accepting both `["Succeeded", "Skipped"]` to
> handle either path — see **Fan-In Join Gate** below.
---
### Fan-In Join Gate (Merge Two Mutually Exclusive Branches)
When two branches are mutually exclusive (only one can succeed per run), use a single
downstream action that accepts `["Succeeded", "Skipped"]` from **both** branches.
The gate fires exactly once regardless of which branch ran:
```json
"Increment_Count": {
"type": "IncrementVariable",
"runAfter": {
"Update_Hi_Res_Metadata": ["Succeeded", "Skipped"],
"Update_Low_Res_Metadata": ["Succeeded", "Skipped"]
},
"inputs": { "name": "LoopCount", "value": 1 }
}
```
> This avoids duplicating the downstream action in each branch. The key insight:
> whichever branch was skipped reports `Skipped` — the gate accepts that state and
> fires once. Only works cleanly when the two branches are truly mutually exclusive
> (e.g. one is `runAfter: [...Failed]` of the other).
---
## Expressions
### Common Expression Patterns
```
Null-safe field access: @item()?['FieldName']
Null guard: @coalesce(item()?['Name'], 'Unknown')
String format: @{variables('firstName')} @{variables('lastName')}
Date today: @utcNow()
Formatted date: @formatDateTime(utcNow(), 'dd/MM/yyyy')
Add days: @addDays(utcNow(), 7)
Array length: @length(variables('myArray'))
Filter array: Use the "Filter array" action (no inline filter expression exists in PA)
Union (new wins): @union(body('New_Data'), outputs('Old_Data'))
Sort: @sort(variables('myArray'), 'Date')
Unix timestamp → date: @formatDateTime(addseconds('1970-1-1', triggerBody()?['created']), 'yyyy-MM-dd')
Date → Unix milliseconds: @div(sub(ticks(startOfDay(item()?['Created'])), ticks(formatDateTime('1970-01-01Z','o'))), 10000)
Date → Unix seconds: @div(sub(ticks(item()?['Start']), ticks('1970-01-01T00:00:00Z')), 10000000)
Unix seconds → datetime: @addSeconds('1970-01-01T00:00:00Z', int(variables('Unix')))
Coalesce as no-else: @coalesce(outputs('Optional_Step'), outputs('Default_Step'))
Flow elapsed minutes: @div(float(sub(ticks(utcNow()), ticks(outputs('Flow_Start')))), 600000000)
HH:mm time string: @formatDateTime(outputs('Local_Datetime'), 'HH:mm')
Response header: @outputs('HTTP_Action')?['headers/X-Request-Id']
Array max (by field): @reverse(sort(body('Select_Items'), 'Date'))[0]
Integer day span: @int(split(dateDifference(outputs('Start'), outputs('End')), '.')[0])
ISO week number: @div(add(dayofyear(addDays(subtractFromTime(date, sub(dayofweek(date),1), 'Day'), 3)), 6), 7)
Join errors to string: @if(equals(length(variables('Errors')),0), null, concat(join(variables('Errors'),', '),' not found.'))
Normalize before compare: @replace(coalesce(outputs('Value'),''),'_',' ')
Robust non-empty check: @greater(length(trim(coalesce(string(outputs('Val')), ''))), 0)
```
### Newlines in Expressions
> **`\n` does NOT produce a newline inside Power Automate expressions.** It is
> treated as a literal backslash + `n` and will either appear verbatim or cause
> a validation error.
Use `decodeUriComponent('%0a')` wherever you need a newline character:
```
Newline (LF): decodeUriComponent('%0a')
CRLF: decodeUriComponent('%0d%0a')
```
Example — multi-line Teams or email body via `concat()`:
```json
"Compose_Message": {
"type": "Compose",
"inputs": "@concat('Hi ', outputs('Get_User')?['body/displayName'], ',', decodeUriComponent('%0a%0a'), 'Your report is ready.', decodeUriComponent('%0a'), '- The Team')"
}
```
Example — `join()` with newline separator:
```json
"Compose_List": {
"type": "Compose",
"inputs": "@join(body('Select_Names'), decodeUriComponent('%0a'))"
}
```
> This is the only reliable way to embed newlines in dynamically built strings
> in Power Automate flow definitions (confirmed against Logic Apps runtime).
---
### Sum an array (XPath trick)
Power Automate has no native `sum()` function. Use XPath on XML instead:
```json
"Prepare_For_Sum": {
"type": "Compose",
"runAfter": {},
"inputs": { "root": { "numbers": "@body('Select_Amounts')" } }
},
"Sum": {
"type": "Compose",
"runAfter": { "Prepare_For_Sum": ["Succeeded"] },
"inputs": "@xpath(xml(outputs('Prepare_For_Sum')), 'sum(/root/numbers)')"
}
```
`Select_Amounts` must output a flat array of numbers (use a **Select** action to extract a single numeric field first). The result is a number you can use directly in conditions or calculations.
> This is the only way to aggregate (sum/min/max) an array without a loop in Power Automate.

View File

@@ -0,0 +1,735 @@
# FlowStudio MCP — Action Patterns: Data Transforms
Array operations, HTTP calls, parsing, and data transformation patterns.
> All examples assume `"runAfter"` is set appropriately.
> `<connectionName>` is the **key** in `connectionReferences` (e.g. `shared_sharepointonline`), not the GUID.
> The GUID goes in the map value's `connectionName` property.
---
## Array Operations
### Select (Reshape / Project an Array)
Transforms each item in an array, keeping only the columns you need or renaming them.
Avoids carrying large objects through the rest of the flow.
```json
"Select_Needed_Columns": {
"type": "Select",
"runAfter": {},
"inputs": {
"from": "@outputs('HTTP_Get_Subscriptions')?['body/data']",
"select": {
"id": "@item()?['id']",
"status": "@item()?['status']",
"trial_end": "@item()?['trial_end']",
"cancel_at": "@item()?['cancel_at']",
"interval": "@item()?['plan']?['interval']"
}
}
}
```
Result reference: `@body('Select_Needed_Columns')` — returns a direct array of reshaped objects.
> Use Select before looping or filtering to reduce payload size and simplify
> downstream expressions. Works on any array — SP results, HTTP responses, variables.
>
> **Tips:**
> - **Single-to-array coercion:** When an API returns a single object but you need
> Select (which requires an array), wrap it: `@array(body('Get_Employee')?['data'])`.
> The output is a 1-element array — access results via `?[0]?['field']`.
> - **Null-normalize optional fields:** Use `@if(empty(item()?['field']), null, item()?['field'])`
> on every optional field to normalize empty strings, missing properties, and empty
> objects to explicit `null`. Ensures consistent downstream `@equals(..., @null)` checks.
> - **Flatten nested objects:** Project nested properties into flat fields:
> ```
> "manager_name": "@if(empty(item()?['manager']?['name']), null, item()?['manager']?['name'])"
> ```
> This enables direct field-level comparison with a flat schema from another source.
---
### Filter Array (Query)
Filters an array to items matching a condition. Use the action form (not the `filter()`
expression) for complex multi-condition logic — it's clearer and easier to maintain.
```json
"Filter_Active_Subscriptions": {
"type": "Query",
"runAfter": {},
"inputs": {
"from": "@body('Select_Needed_Columns')",
"where": "@and(or(equals(item().status, 'trialing'), equals(item().status, 'active')), equals(item().cancel_at, null))"
}
}
```
Result reference: `@body('Filter_Active_Subscriptions')` — direct filtered array.
> Tip: run multiple Filter Array actions on the same source array to create
> named buckets (e.g. active, being-canceled, fully-canceled), then use
> `coalesce(first(body('Filter_A')), first(body('Filter_B')), ...)` to pick
> the highest-priority match without any loops.
---
### Create CSV Table (Array → CSV String)
Converts an array of objects into a CSV-formatted string — no connector call, no code.
Use after a `Select` or `Filter Array` to export data or pass it to a file-write action.
```json
"Create_CSV": {
"type": "Table",
"runAfter": {},
"inputs": {
"from": "@body('Select_Output_Columns')",
"format": "CSV"
}
}
```
Result reference: `@body('Create_CSV')` — a plain string with header row + data rows.
```json
// Custom column order / renamed headers:
"Create_CSV_Custom": {
"type": "Table",
"inputs": {
"from": "@body('Select_Output_Columns')",
"format": "CSV",
"columns": [
{ "header": "Date", "value": "@item()?['transactionDate']" },
{ "header": "Amount", "value": "@item()?['amount']" },
{ "header": "Description", "value": "@item()?['description']" }
]
}
}
```
> Without `columns`, headers are taken from the object property names in the source array.
> With `columns`, you control header names and column order explicitly.
>
> The output is a raw string. Write it to a file with `CreateFile` or `UpdateFile`
> (set `body` to `@body('Create_CSV')`), or store in a variable with `SetVariable`.
>
> If source data came from Power BI's `ExecuteDatasetQuery`, column names will be
> wrapped in square brackets (e.g. `[Amount]`). Strip them before writing:
> `@replace(replace(body('Create_CSV'),'[',''),']','')`
---
### range() + Select for Array Generation
`range(0, N)` produces an integer sequence `[0, 1, 2, …, N-1]`. Pipe it through
a Select action to generate date series, index grids, or any computed array
without a loop:
```json
// Generate 14 consecutive dates starting from a base date
"Generate_Date_Series": {
"type": "Select",
"inputs": {
"from": "@range(0, 14)",
"select": "@addDays(outputs('Base_Date'), item(), 'yyyy-MM-dd')"
}
}
```
Result: `@body('Generate_Date_Series')``["2025-01-06", "2025-01-07", …, "2025-01-19"]`
```json
// Flatten a 2D array (rows × cols) into 1D using arithmetic indexing
"Flatten_Grid": {
"type": "Select",
"inputs": {
"from": "@range(0, mul(length(outputs('Rows')), length(outputs('Cols'))))",
"select": {
"row": "@outputs('Rows')[div(item(), length(outputs('Cols')))]",
"col": "@outputs('Cols')[mod(item(), length(outputs('Cols')))]"
}
}
}
```
> `range()` is zero-based. The Cartesian product pattern above uses `div(i, cols)`
> for the row index and `mod(i, cols)` for the column index — equivalent to a
> nested for-loop flattened into a single pass. Useful for generating time-slot ×
> date grids, shift × location assignments, etc.
---
### Dynamic Dictionary via json(concat(join()))
When you need O(1) key→value lookups at runtime and Power Automate has no native
dictionary type, build one from an array using Select + join + json:
```json
"Build_Key_Value_Pairs": {
"type": "Select",
"inputs": {
"from": "@body('Get_Lookup_Items')?['value']",
"select": "@concat('\"', item()?['Key'], '\":\"', item()?['Value'], '\"')"
}
},
"Assemble_Dictionary": {
"type": "Compose",
"inputs": "@json(concat('{', join(body('Build_Key_Value_Pairs'), ','), '}'))"
}
```
Lookup: `@outputs('Assemble_Dictionary')?['myKey']`
```json
// Practical example: date → rate-code lookup for business rules
"Build_Holiday_Rates": {
"type": "Select",
"inputs": {
"from": "@body('Get_Holidays')?['value']",
"select": "@concat('\"', formatDateTime(item()?['Date'], 'yyyy-MM-dd'), '\":\"', item()?['RateCode'], '\"')"
}
},
"Holiday_Dict": {
"type": "Compose",
"inputs": "@json(concat('{', join(body('Build_Holiday_Rates'), ','), '}'))"
}
```
Then inside a loop: `@coalesce(outputs('Holiday_Dict')?[item()?['Date']], 'Standard')`
> The `json(concat('{', join(...), '}'))` pattern works for string values. For numeric
> or boolean values, omit the inner escaped quotes around the value portion.
> Keys must be unique — duplicate keys silently overwrite earlier ones.
> This replaces deeply nested `if(equals(key,'A'),'X', if(equals(key,'B'),'Y', ...))` chains.
---
### union() for Changed-Field Detection
When you need to find records where *any* of several fields has changed, run one
`Filter Array` per field and `union()` the results. This avoids a complex
multi-condition filter and produces a clean deduplicated set:
```json
"Filter_Name_Changed": {
"type": "Query",
"inputs": { "from": "@body('Existing_Records')",
"where": "@not(equals(item()?['name'], item()?['dest_name']))" }
},
"Filter_Status_Changed": {
"type": "Query",
"inputs": { "from": "@body('Existing_Records')",
"where": "@not(equals(item()?['status'], item()?['dest_status']))" }
},
"All_Changed": {
"type": "Compose",
"inputs": "@union(body('Filter_Name_Changed'), body('Filter_Status_Changed'))"
}
```
Reference: `@outputs('All_Changed')` — deduplicated array of rows where anything changed.
> `union()` deduplicates by object identity, so a row that changed in both fields
> appears once. Add more `Filter_*_Changed` inputs to `union()` as needed:
> `@union(body('F1'), body('F2'), body('F3'))`
---
### File-Content Change Gate
Before running expensive processing on a file or blob, compare its current content
to a stored baseline. Skip entirely if nothing has changed — makes sync flows
idempotent and safe to re-run or schedule aggressively.
```json
"Get_File_From_Source": { ... },
"Get_Stored_Baseline": { ... },
"Condition_File_Changed": {
"type": "If",
"expression": {
"not": {
"equals": [
"@base64(body('Get_File_From_Source'))",
"@body('Get_Stored_Baseline')"
]
}
},
"actions": {
"Update_Baseline": { "...": "overwrite stored copy with new content" },
"Process_File": { "...": "all expensive work goes here" }
},
"else": { "actions": {} }
}
```
> Store the baseline as a file in SharePoint or blob storage — `base64()`-encode the
> live content before comparing so binary and text files are handled uniformly.
> Write the new baseline **before** processing so a re-run after a partial failure
> does not re-process the same file again.
---
### Set-Join for Sync (Update Detection without Nested Loops)
When syncing a source collection into a destination (e.g. API response → SharePoint list,
CSV → database), avoid nested `Apply to each` loops to find changed records.
Instead, **project flat key arrays** and use `contains()` to perform set operations —
zero nested loops, and the final loop only touches changed items.
**Full insert/update/delete sync pattern:**
```json
// Step 1 — Project a flat key array from the DESTINATION (e.g. SharePoint)
"Select_Dest_Keys": {
"type": "Select",
"inputs": {
"from": "@outputs('Get_Dest_Items')?['body/value']",
"select": "@item()?['Title']"
}
}
// → ["KEY1", "KEY2", "KEY3", ...]
// Step 2 — INSERT: source rows whose key is NOT in destination
"Filter_To_Insert": {
"type": "Query",
"inputs": {
"from": "@body('Source_Array')",
"where": "@not(contains(body('Select_Dest_Keys'), item()?['key']))"
}
}
// → Apply to each Filter_To_Insert → CreateItem
// Step 3 — INNER JOIN: source rows that exist in destination
"Filter_Already_Exists": {
"type": "Query",
"inputs": {
"from": "@body('Source_Array')",
"where": "@contains(body('Select_Dest_Keys'), item()?['key'])"
}
}
// Step 4 — UPDATE: one Filter per tracked field, then union them
"Filter_Field1_Changed": {
"type": "Query",
"inputs": {
"from": "@body('Filter_Already_Exists')",
"where": "@not(equals(item()?['field1'], item()?['dest_field1']))"
}
}
"Filter_Field2_Changed": {
"type": "Query",
"inputs": {
"from": "@body('Filter_Already_Exists')",
"where": "@not(equals(item()?['field2'], item()?['dest_field2']))"
}
}
"Union_Changed": {
"type": "Compose",
"inputs": "@union(body('Filter_Field1_Changed'), body('Filter_Field2_Changed'))"
}
// → rows where ANY tracked field differs
// Step 5 — Resolve destination IDs for changed rows (no nested loop)
"Select_Changed_Keys": {
"type": "Select",
"inputs": { "from": "@outputs('Union_Changed')", "select": "@item()?['key']" }
}
"Filter_Dest_Items_To_Update": {
"type": "Query",
"inputs": {
"from": "@outputs('Get_Dest_Items')?['body/value']",
"where": "@contains(body('Select_Changed_Keys'), item()?['Title'])"
}
}
// Step 6 — Single loop over changed items only
"Apply_to_each_Update": {
"type": "Foreach",
"foreach": "@body('Filter_Dest_Items_To_Update')",
"actions": {
"Get_Source_Row": {
"type": "Query",
"inputs": {
"from": "@outputs('Union_Changed')",
"where": "@equals(item()?['key'], items('Apply_to_each_Update')?['Title'])"
}
},
"Update_Item": {
"...": "...",
"id": "@items('Apply_to_each_Update')?['ID']",
"item/field1": "@first(body('Get_Source_Row'))?['field1']"
}
}
}
// Step 7 — DELETE: destination keys NOT in source
"Select_Source_Keys": {
"type": "Select",
"inputs": { "from": "@body('Source_Array')", "select": "@item()?['key']" }
}
"Filter_To_Delete": {
"type": "Query",
"inputs": {
"from": "@outputs('Get_Dest_Items')?['body/value']",
"where": "@not(contains(body('Select_Source_Keys'), item()?['Title']))"
}
}
// → Apply to each Filter_To_Delete → DeleteItem
```
> **Why this beats nested loops**: the naive approach (for each dest item, scan source)
> is O(n × m) and hits Power Automate's 100k-action run limit fast on large lists.
> This pattern is O(n + m): one pass to build key arrays, one pass per filter.
> The update loop in Step 6 only iterates *changed* records — often a tiny fraction
> of the full collection. Run Steps 2/4/7 in **parallel Scopes** for further speed.
---
### First-or-Null Single-Row Lookup
Use `first()` on the result array to extract one record without a loop.
Then null-check the output to guard downstream actions.
```json
"Get_First_Match": {
"type": "Compose",
"runAfter": { "Get_SP_Items": ["Succeeded"] },
"inputs": "@first(outputs('Get_SP_Items')?['body/value'])"
}
```
In a Condition, test for no-match with the **`@null` literal** (not `empty()`):
```json
"Condition": {
"type": "If",
"expression": {
"not": {
"equals": [
"@outputs('Get_First_Match')",
"@null"
]
}
}
}
```
Access fields on the matched row: `@outputs('Get_First_Match')?['FieldName']`
> Use this instead of `Apply to each` when you only need one matching record.
> `first()` on an empty array returns `null`; `empty()` is for arrays/strings,
> not scalars — using it on a `first()` result causes a runtime error.
---
## HTTP & Parsing
### HTTP Action (External API)
```json
"Call_External_API": {
"type": "Http",
"runAfter": {},
"inputs": {
"method": "POST",
"uri": "https://api.example.com/endpoint",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer @{variables('apiToken')}"
},
"body": {
"data": "@outputs('Compose_Payload')"
},
"retryPolicy": {
"type": "Fixed",
"count": 3,
"interval": "PT10S"
}
}
}
```
Response reference: `@outputs('Call_External_API')?['body']`
#### Variant: ActiveDirectoryOAuth (Service-to-Service)
For calling APIs that require Azure AD client-credentials (e.g., Microsoft Graph),
use in-line OAuth instead of a Bearer token variable:
```json
"Call_Graph_API": {
"type": "Http",
"runAfter": {},
"inputs": {
"method": "GET",
"uri": "https://graph.microsoft.com/v1.0/users?$search=\"employeeId:@{variables('Code')}\"&$select=id,displayName",
"headers": {
"Content-Type": "application/json",
"ConsistencyLevel": "eventual"
},
"authentication": {
"type": "ActiveDirectoryOAuth",
"authority": "https://login.microsoftonline.com",
"tenant": "<tenant-id>",
"audience": "https://graph.microsoft.com",
"clientId": "<app-registration-id>",
"secret": "@parameters('graphClientSecret')"
}
}
}
```
> **When to use:** Calling Microsoft Graph, Azure Resource Manager, or any
> Azure AD-protected API from a flow without a premium connector.
>
> The `authentication` block handles the entire OAuth client-credentials flow
> transparently — no manual token acquisition step needed.
>
> `ConsistencyLevel: eventual` is required for Graph `$search` queries.
> Without it, `$search` returns 400.
>
> For PATCH/PUT writes, the same `authentication` block works — just change
> `method` and add a `body`.
>
> ⚠️ **Never hardcode `secret` inline.** Use `@parameters('graphClientSecret')`
> and declare it in the flow's `parameters` block (type `securestring`). This
> prevents the secret from appearing in run history or being readable via
> `get_live_flow`. Declare the parameter like:
> ```json
> "parameters": {
> "graphClientSecret": { "type": "securestring", "defaultValue": "" }
> }
> ```
> Then pass the real value via the flow's connections or environment variables
> — never commit it to source control.
---
### HTTP Response (Return to Caller)
Used in HTTP-triggered flows to send a structured reply back to the caller.
Must run before the flow times out (default 2 min for synchronous HTTP).
```json
"Response": {
"type": "Response",
"runAfter": {},
"inputs": {
"statusCode": 200,
"headers": {
"Content-Type": "application/json"
},
"body": {
"status": "success",
"message": "@{outputs('Compose_Result')}"
}
}
}
```
> **PowerApps / low-code caller pattern**: always return `statusCode: 200` with a
> `status` field in the body (`"success"` / `"error"`). PowerApps HTTP actions
> do not handle non-2xx responses gracefully — the caller should inspect
> `body.status` rather than the HTTP status code.
>
> Use multiple Response actions — one per branch — so each path returns
> an appropriate message. Only one will execute per run.
---
### Child Flow Call (Parent→Child via HTTP POST)
Power Automate supports parent→child orchestration by calling a child flow's
HTTP trigger URL directly. The parent sends an HTTP POST and blocks until the
child returns a `Response` action. The child flow uses a `manual` (Request) trigger.
```json
// PARENT — call child flow and wait for its response
"Call_Child_Flow": {
"type": "Http",
"inputs": {
"method": "POST",
"uri": "https://prod-XX.australiasoutheast.logic.azure.com:443/workflows/<workflowId>/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=<SAS>",
"headers": { "Content-Type": "application/json" },
"body": {
"ID": "@triggerBody()?['ID']",
"WeekEnd": "@triggerBody()?['WeekEnd']",
"Payload": "@variables('dataArray')"
},
"retryPolicy": { "type": "none" }
},
"operationOptions": "DisableAsyncPattern",
"runtimeConfiguration": {
"contentTransfer": { "transferMode": "Chunked" }
},
"limit": { "timeout": "PT2H" }
}
```
```json
// CHILD — manual trigger receives the JSON body
// (trigger definition)
"manual": {
"type": "Request",
"kind": "Http",
"inputs": {
"schema": {
"type": "object",
"properties": {
"ID": { "type": "string" },
"WeekEnd": { "type": "string" },
"Payload": { "type": "array" }
}
}
}
}
// CHILD — return result to parent
"Response_Success": {
"type": "Response",
"inputs": {
"statusCode": 200,
"headers": { "Content-Type": "application/json" },
"body": { "Result": "Success", "Count": "@length(variables('processed'))" }
}
}
```
> **`retryPolicy: none`** — critical on the parent's HTTP call. Without it, a child
> flow timeout triggers retries, spawning duplicate child runs.
>
> **`DisableAsyncPattern`** — prevents the parent from treating a 202 Accepted as
> completion. The parent will block until the child sends its `Response`.
>
> **`transferMode: Chunked`** — enable when passing large arrays (>100 KB) to the child;
> avoids request-size limits.
>
> **`limit.timeout: PT2H`** — raise the default 2-minute HTTP timeout for long-running
> children. Max is PT24H.
>
> The child flow's trigger URL contains a SAS token (`sig=...`) that authenticates
> the call. Copy it from the child flow's trigger properties panel. The URL changes
> if the trigger is deleted and re-created.
---
### Parse JSON
```json
"Parse_Response": {
"type": "ParseJson",
"runAfter": {},
"inputs": {
"content": "@outputs('Call_External_API')?['body']",
"schema": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"items": {
"type": "array",
"items": { "type": "object" }
}
}
}
}
}
```
Access parsed values: `@body('Parse_Response')?['name']`
---
### Manual CSV → JSON (No Premium Action)
Parse a raw CSV string into an array of objects using only built-in expressions.
Avoids the premium "Parse CSV" connector action.
```json
"Delimiter": {
"type": "Compose",
"inputs": ","
},
"Strip_Quotes": {
"type": "Compose",
"inputs": "@replace(body('Get_File_Content'), '\"', '')"
},
"Detect_Line_Ending": {
"type": "Compose",
"inputs": "@if(equals(indexOf(outputs('Strip_Quotes'), decodeUriComponent('%0D%0A')), -1), if(equals(indexOf(outputs('Strip_Quotes'), decodeUriComponent('%0A')), -1), decodeUriComponent('%0D'), decodeUriComponent('%0A')), decodeUriComponent('%0D%0A'))"
},
"Headers": {
"type": "Compose",
"inputs": "@split(first(split(outputs('Strip_Quotes'), outputs('Detect_Line_Ending'))), outputs('Delimiter'))"
},
"Data_Rows": {
"type": "Compose",
"inputs": "@skip(split(outputs('Strip_Quotes'), outputs('Detect_Line_Ending')), 1)"
},
"Select_CSV_Body": {
"type": "Select",
"inputs": {
"from": "@outputs('Data_Rows')",
"select": {
"@{outputs('Headers')[0]}": "@split(item(), outputs('Delimiter'))[0]",
"@{outputs('Headers')[1]}": "@split(item(), outputs('Delimiter'))[1]",
"@{outputs('Headers')[2]}": "@split(item(), outputs('Delimiter'))[2]"
}
}
},
"Filter_Empty_Rows": {
"type": "Query",
"inputs": {
"from": "@body('Select_CSV_Body')",
"where": "@not(equals(item()?[outputs('Headers')[0]], null))"
}
}
```
Result: `@body('Filter_Empty_Rows')` — array of objects with header names as keys.
> **`Detect_Line_Ending`** handles CRLF (Windows), LF (Unix), and CR (old Mac) automatically
> using `indexOf()` with `decodeUriComponent('%0D%0A' / '%0A' / '%0D')`.
>
> **Dynamic key names in `Select`**: `@{outputs('Headers')[0]}` as a JSON key in a
> `Select` shape sets the output property name at runtime from the header row —
> this works as long as the expression is in `@{...}` interpolation syntax.
>
> **Columns with embedded commas**: if field values can contain the delimiter,
> use `length(split(row, ','))` in a Switch to detect the column count and manually
> reassemble the split fragments: `@concat(split(item(),',')[1],',',split(item(),',')[2])`
---
### ConvertTimeZone (Built-in, No Connector)
Converts a timestamp between timezones with no API call or connector licence cost.
Format string `"g"` produces short locale date+time (`M/d/yyyy h:mm tt`).
```json
"Convert_to_Local_Time": {
"type": "Expression",
"kind": "ConvertTimeZone",
"runAfter": {},
"inputs": {
"baseTime": "@{outputs('UTC_Timestamp')}",
"sourceTimeZone": "UTC",
"destinationTimeZone": "Taipei Standard Time",
"formatString": "g"
}
}
```
Result reference: `@body('Convert_to_Local_Time')`**not** `outputs()`, unlike most actions.
Common `formatString` values: `"g"` (short), `"f"` (full), `"yyyy-MM-dd"`, `"HH:mm"`
Common timezone strings: `"UTC"`, `"AUS Eastern Standard Time"`, `"Taipei Standard Time"`,
`"Singapore Standard Time"`, `"GMT Standard Time"`
> This is `type: Expression, kind: ConvertTimeZone` — a built-in Logic Apps action,
> not a connector. No connection reference needed. Reference the output via
> `body()` (not `outputs()`), otherwise the expression returns null.

View File

@@ -0,0 +1,108 @@
# Common Build Patterns
Complete flow definition templates ready to copy and customize.
---
## Pattern: Recurrence + SharePoint list read + Teams notification
```json
{
"triggers": {
"Recurrence": {
"type": "Recurrence",
"recurrence": { "frequency": "Day", "interval": 1,
"startTime": "2026-01-01T08:00:00Z",
"timeZone": "AUS Eastern Standard Time" }
}
},
"actions": {
"Get_SP_Items": {
"type": "OpenApiConnection",
"runAfter": {},
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline",
"connectionName": "shared_sharepointonline",
"operationId": "GetItems"
},
"parameters": {
"dataset": "https://mytenant.sharepoint.com/sites/mysite",
"table": "MyList",
"$filter": "Status eq 'Active'",
"$top": 500
}
}
},
"Apply_To_Each": {
"type": "Foreach",
"runAfter": { "Get_SP_Items": ["Succeeded"] },
"foreach": "@outputs('Get_SP_Items')?['body/value']",
"actions": {
"Post_Teams_Message": {
"type": "OpenApiConnection",
"runAfter": {},
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_teams",
"connectionName": "shared_teams",
"operationId": "PostMessageToConversation"
},
"parameters": {
"poster": "Flow bot",
"location": "Channel",
"body/recipient": {
"groupId": "<team-id>",
"channelId": "<channel-id>"
},
"body/messageBody": "Item: @{items('Apply_To_Each')?['Title']}"
}
}
}
},
"operationOptions": "Sequential"
}
}
}
```
---
## Pattern: HTTP trigger (webhook / Power App call)
```json
{
"triggers": {
"manual": {
"type": "Request",
"kind": "Http",
"inputs": {
"schema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"value": { "type": "number" }
}
}
}
}
},
"actions": {
"Compose_Response": {
"type": "Compose",
"runAfter": {},
"inputs": "Received: @{triggerBody()?['name']} = @{triggerBody()?['value']}"
},
"Response": {
"type": "Response",
"runAfter": { "Compose_Response": ["Succeeded"] },
"inputs": {
"statusCode": 200,
"body": { "status": "ok", "message": "@{outputs('Compose_Response')}" }
}
}
}
}
```
Access body values: `@triggerBody()?['name']`

View File

@@ -0,0 +1,225 @@
# FlowStudio MCP — Flow Definition Schema
The full JSON structure expected by `update_live_flow` (and returned by `get_live_flow`).
---
## Top-Level Shape
```json
{
"$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"$connections": {
"defaultValue": {},
"type": "Object"
}
},
"triggers": {
"<TriggerName>": { ... }
},
"actions": {
"<ActionName>": { ... }
},
"outputs": {}
}
```
---
## `triggers`
Exactly one trigger per flow definition. The key name is arbitrary but
conventional names are used (e.g. `Recurrence`, `manual`, `When_a_new_email_arrives`).
See [trigger-types.md](trigger-types.md) for all trigger templates.
---
## `actions`
Dictionary of action definitions keyed by unique action name.
Key names may not contain spaces — use underscores.
Each action must include:
- `type` — action type identifier
- `runAfter` — map of upstream action names → status conditions array
- `inputs` — action-specific input configuration
See [action-patterns-core.md](action-patterns-core.md), [action-patterns-data.md](action-patterns-data.md),
and [action-patterns-connectors.md](action-patterns-connectors.md) for templates.
### Optional Action Properties
Beyond the required `type`, `runAfter`, and `inputs`, actions can include:
| Property | Purpose |
|---|---|
| `runtimeConfiguration` | Pagination, concurrency, secure data, chunked transfer |
| `operationOptions` | `"Sequential"` for Foreach, `"DisableAsyncPattern"` for HTTP |
| `limit` | Timeout override (e.g. `{"timeout": "PT2H"}`) |
#### `runtimeConfiguration` Variants
**Pagination** (SharePoint Get Items with large lists):
```json
"runtimeConfiguration": {
"paginationPolicy": {
"minimumItemCount": 5000
}
}
```
> Without this, Get Items silently caps at 256 results. Set `minimumItemCount`
> to the maximum rows you expect. Required for any SharePoint list over 256 items.
**Concurrency** (parallel Foreach):
```json
"runtimeConfiguration": {
"concurrency": {
"repetitions": 20
}
}
```
**Secure inputs/outputs** (mask values in run history):
```json
"runtimeConfiguration": {
"secureData": {
"properties": ["inputs", "outputs"]
}
}
```
> Use on actions that handle credentials, tokens, or PII. Masked values show
> as `"<redacted>"` in the flow run history UI and API responses.
**Chunked transfer** (large HTTP payloads):
```json
"runtimeConfiguration": {
"contentTransfer": {
"transferMode": "Chunked"
}
}
```
> Enable on HTTP actions sending or receiving bodies >100 KB (e.g. parent→child
> flow calls with large arrays).
---
## `runAfter` Rules
The first action in a branch has `"runAfter": {}` (empty — runs after trigger).
Subsequent actions declare their dependency:
```json
"My_Action": {
"runAfter": {
"Previous_Action": ["Succeeded"]
}
}
```
Multiple upstream dependencies:
```json
"runAfter": {
"Action_A": ["Succeeded"],
"Action_B": ["Succeeded", "Skipped"]
}
```
Error-handling action (runs when upstream failed):
```json
"Log_Error": {
"runAfter": {
"Risky_Action": ["Failed"]
}
}
```
---
## `parameters` (Flow-Level Input Parameters)
Optional. Define reusable values at the flow level:
```json
"parameters": {
"listName": {
"type": "string",
"defaultValue": "MyList"
},
"maxItems": {
"type": "integer",
"defaultValue": 100
}
}
```
Reference: `@parameters('listName')` in expression strings.
---
## `outputs`
Rarely used in cloud flows. Leave as `{}` unless the flow is called
as a child flow and needs to return values.
For child flows that return data:
```json
"outputs": {
"resultData": {
"type": "object",
"value": "@outputs('Compose_Result')"
}
}
```
---
## Scoped Actions (Inside Scope Block)
Actions that need to be grouped for error handling or clarity:
```json
"Scope_Main_Process": {
"type": "Scope",
"runAfter": {},
"actions": {
"Step_One": { ... },
"Step_Two": { "runAfter": { "Step_One": ["Succeeded"] }, ... }
}
}
```
---
## Full Minimal Example
```json
{
"$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
"contentVersion": "1.0.0.0",
"triggers": {
"Recurrence": {
"type": "Recurrence",
"recurrence": {
"frequency": "Week",
"interval": 1,
"schedule": { "weekDays": ["Monday"] },
"startTime": "2026-01-05T09:00:00Z",
"timeZone": "AUS Eastern Standard Time"
}
}
},
"actions": {
"Compose_Greeting": {
"type": "Compose",
"runAfter": {},
"inputs": "Good Monday!"
}
},
"outputs": {}
}
```

View File

@@ -0,0 +1,211 @@
# FlowStudio MCP — Trigger Types
Copy-paste trigger definitions for Power Automate flow definitions.
---
## Recurrence
Run on a schedule.
```json
"Recurrence": {
"type": "Recurrence",
"recurrence": {
"frequency": "Day",
"interval": 1,
"startTime": "2026-01-01T08:00:00Z",
"timeZone": "AUS Eastern Standard Time"
}
}
```
Weekly on specific days:
```json
"Recurrence": {
"type": "Recurrence",
"recurrence": {
"frequency": "Week",
"interval": 1,
"schedule": {
"weekDays": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
},
"startTime": "2026-01-05T09:00:00Z",
"timeZone": "AUS Eastern Standard Time"
}
}
```
Common `timeZone` values:
- `"AUS Eastern Standard Time"` — Sydney/Melbourne (UTC+10/+11)
- `"UTC"` — Universal time
- `"E. Australia Standard Time"` — Brisbane (UTC+10 no DST)
- `"New Zealand Standard Time"` — Auckland (UTC+12/+13)
- `"Pacific Standard Time"` — Los Angeles (UTC-8/-7)
- `"GMT Standard Time"` — London (UTC+0/+1)
---
## Manual (HTTP Request / Power Apps)
Receive an HTTP POST with a JSON body.
```json
"manual": {
"type": "Request",
"kind": "Http",
"inputs": {
"schema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"value": { "type": "integer" }
},
"required": ["name"]
}
}
}
```
Access values: `@triggerBody()?['name']`
Trigger URL available after saving: `@listCallbackUrl()`
#### No-Schema Variant (Accept Arbitrary JSON)
When the incoming payload structure is unknown or varies, omit the schema
to accept any valid JSON body without validation:
```json
"manual": {
"type": "Request",
"kind": "Http",
"inputs": {
"schema": {}
}
}
```
Access any field dynamically: `@triggerBody()?['anyField']`
> Use this for external webhooks (Stripe, GitHub, Employment Hero, etc.) where the
> payload shape may change or is not fully documented. The flow accepts any
> JSON without returning 400 for unexpected properties.
---
## Automated (SharePoint Item Created)
```json
"When_an_item_is_created": {
"type": "OpenApiConnectionNotification",
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline",
"connectionName": "<connectionName>",
"operationId": "OnNewItem"
},
"parameters": {
"dataset": "https://mytenant.sharepoint.com/sites/mysite",
"table": "MyList"
},
"subscribe": {
"body": { "notificationUrl": "@listCallbackUrl()" },
"queries": {
"dataset": "https://mytenant.sharepoint.com/sites/mysite",
"table": "MyList"
}
}
}
}
```
Access trigger data: `@triggerBody()?['ID']`, `@triggerBody()?['Title']`, etc.
---
## Automated (SharePoint Item Modified)
```json
"When_an_existing_item_is_modified": {
"type": "OpenApiConnectionNotification",
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline",
"connectionName": "<connectionName>",
"operationId": "OnUpdatedItem"
},
"parameters": {
"dataset": "https://mytenant.sharepoint.com/sites/mysite",
"table": "MyList"
},
"subscribe": {
"body": { "notificationUrl": "@listCallbackUrl()" },
"queries": {
"dataset": "https://mytenant.sharepoint.com/sites/mysite",
"table": "MyList"
}
}
}
}
```
---
## Automated (Outlook: When New Email Arrives)
```json
"When_a_new_email_arrives": {
"type": "OpenApiConnectionNotification",
"inputs": {
"host": {
"apiId": "/providers/Microsoft.PowerApps/apis/shared_office365",
"connectionName": "<connectionName>",
"operationId": "OnNewEmail"
},
"parameters": {
"folderId": "Inbox",
"to": "monitored@contoso.com",
"isHTML": true
},
"subscribe": {
"body": { "notificationUrl": "@listCallbackUrl()" }
}
}
}
```
---
## Child Flow (Called by Another Flow)
```json
"manual": {
"type": "Request",
"kind": "Button",
"inputs": {
"schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": { "type": "object" }
}
}
}
}
}
```
Access parent-supplied data: `@triggerBody()?['items']`
To return data to the parent, add a `Response` action:
```json
"Respond_to_Parent": {
"type": "Response",
"runAfter": { "Compose_Result": ["Succeeded"] },
"inputs": {
"statusCode": 200,
"body": "@outputs('Compose_Result')"
}
}
```