Installation

Install with CLI Recommended
gh skills-hub install flowstudio-power-automate-build

Don't have the extension? Run gh extension install samueltauil/skills-hub first.

Download and extract to your repository:

.github/skills/flowstudio-power-automate-build/

Extract the ZIP to .github/skills/ in your repo. The folder name must match flowstudio-power-automate-build for Copilot to auto-discover it.

Skill Files (7)

SKILL.md 19.2 KB
---
name: flowstudio-power-automate-build
description: >-
  Build, scaffold, and deploy Power Automate cloud flows using the FlowStudio
  MCP server. Your agent constructs flow definitions, wires connections, deploys,
  and tests β€” all via MCP without opening the portal.
  Load this skill when asked to: create a flow, build a new flow,
  deploy a flow definition, scaffold a Power Automate workflow, construct a flow
  JSON, update an existing flow's actions, patch a flow definition, add actions
  to a flow, wire up connections, or generate a workflow definition from scratch.
  Requires a FlowStudio MCP subscription β€” see https://mcp.flowstudio.app
---

# Build & Deploy Power Automate Flows with FlowStudio MCP

Step-by-step guide for constructing and deploying Power Automate cloud flows
programmatically through the FlowStudio MCP server.

**Prerequisite**: A FlowStudio MCP server must be reachable with a valid JWT.
See the `flowstudio-power-automate-mcp` skill for connection setup.
Subscribe at https://mcp.flowstudio.app

Workflow:
1. Load current build tools.
2. Check for an existing flow.
3. Resolve connection references.
4. Build the definition.
5. Deploy.
6. Verify.
7. Test.

---

## Source of Truth

> **Always call `list_skills` / `tool_search` first** to confirm available tool
> names and parameter schemas. Tool names and parameters may change between
> server versions.
> This skill covers response shapes, behavioral notes, and build patterns β€”
> things tool schemas cannot tell you. If this document disagrees with
> `tool_search` or a real API response, the API wins.

---

## Python Helper

```python
import json, urllib.request

MCP_URL   = "https://mcp.flowstudio.app/mcp"
MCP_TOKEN = "<YOUR_JWT_TOKEN>"

def mcp(tool, **kwargs):
    payload = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
                          "params": {"name": tool, "arguments": kwargs}}).encode()
    req = urllib.request.Request(MCP_URL, data=payload,
        headers={"x-api-key": MCP_TOKEN, "Content-Type": "application/json",
                 "User-Agent": "FlowStudio-MCP/1.0"})
    try:
        resp = urllib.request.urlopen(req, timeout=120)
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"MCP HTTP {e.code}: {body[:200]}") from e
    raw = json.loads(resp.read())
    if "error" in raw:
        raise RuntimeError(f"MCP error: {json.dumps(raw['error'])}")
    return json.loads(raw["result"]["content"][0]["text"])

ENV = "<environment-id>"  # e.g. Default-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
```

---

## 0. Load the Current Build Tools

For a brand-new flow, load the server's `create-flow` bundle. For editing an
existing flow, load `build-flow`. This keeps the agent aligned with the MCP
server's current schema before constructing JSON.

```python
schemas = mcp("tool_search", query="skill:create-flow")
# Includes list_live_environments, list_live_connections,
# describe_live_connector, get_live_dynamic_options, update_live_flow.
```

If you need a tool outside the bundle, load it explicitly:

```python
mcp("tool_search", query="select:get_live_dynamic_properties")
```

---

## 1. Safety Check: Does the Flow Already Exist?

Always look before you build to avoid duplicates:

```python
results = mcp("list_live_flows",
    environmentName=ENV,
    mode="owner",
    search="My New Flow",
    top=20)

# list_live_flows returns { "flows": [...], "mode": "...", ... }
matches = [f for f in results["flows"]
           if "My New Flow".lower() in f["displayName"].lower()]

if len(matches) > 0:
    # Flow exists β€” modify rather than create
    FLOW_ID = matches[0]["id"]   # plain UUID from list_live_flows
    print(f"Existing flow: {FLOW_ID}")
    defn = mcp("get_live_flow", environmentName=ENV, flowName=FLOW_ID)
else:
    print("Flow not found β€” building from scratch")
    FLOW_ID = None
```

For very large environments, `list_live_flows` may return a continuation URL.
Pass it back as `continuationUrl` with the same `mode` to retrieve the next
batch. Use `mode="admin"` only when the user needs all environment flows and
the MCP identity has admin rights.

---

## 2. Obtain Connection References

Every connector action needs a `connectionName` that points to a key in the
flow's `connectionReferences` map. That key links to an authenticated connection
in the environment.

> **MANDATORY**: You MUST call `list_live_connections` first β€” do NOT ask the
> user for connection names or GUIDs. The API returns the exact values you need.
> Only prompt the user if the API confirms that required connections are missing.

### 2a β€” Find active connections

```python
conns = mcp("list_live_connections", environmentName=ENV)
active = [c for c in conns["connections"]
          if c["statuses"][0]["status"] == "Connected"]
conn_map = {c["connectorName"]: c["id"] for c in active}
```

For a known connector, pass `search` to reduce output and get paste-ready
`connectionReferenceTemplate` and `hostTemplate` values:

```python
sp_conns = mcp("list_live_connections",
    environmentName=ENV,
    search="shared_sharepointonline")
```

### 2b β€” Determine which connectors the flow needs

Common connector API names: SharePoint `shared_sharepointonline`, Outlook
`shared_office365`, Teams `shared_teams`, Approvals `shared_approvals`,
OneDrive `shared_onedriveforbusiness`, Excel `shared_excelonlinebusiness`,
Dataverse `shared_commondataserviceforapps`, Forms `shared_microsoftforms`.

Flows that need no connectors, such as Recurrence + Compose + HTTP only, can
omit `connectionReferences`.

### 2c β€” If connections are missing, guide the user

```python
connectors_needed = ["shared_sharepointonline", "shared_office365"]  # adjust per flow
missing = [c for c in connectors_needed if c not in conn_map]
if missing:
    # STOP: connections require browser OAuth consent.
    # Ask the user to create the missing connector connections in the
    # selected environment, then re-run list_live_connections.
    raise Exception(f"Missing active connections: {missing}")
```

### 2d β€” Build the connectionReferences block

```python
connection_references = {}
host_templates = {}
for connector in connectors_needed:
    c = next(c for c in active if c["connectorName"] == connector)
    connection_references[connector] = c.get("connectionReferenceTemplate") or {
        "connectionName": c["id"],   # the connection id from list_live_connections
        "source": "Invoker",
        "id": f"/providers/Microsoft.PowerApps/apis/{connector}"
    }
    host_templates[connector] = c.get("hostTemplate") or {
        "connectionName": connector
    }
```

In Step 3 action JSON, `inputs.host.connectionName` must be the map key such as
`shared_teams`, not the GUID. The GUID belongs only inside the
`connectionReferences[connector].connectionName` value. If an existing flow uses
the same connectors, you may also copy its `properties.connectionReferences`
from `get_live_flow`.

---

## 3. Build the Flow Definition

Construct the definition object. See [flow-schema.md](references/flow-schema.md)
for the full schema and these action pattern references for copy-paste templates:
- [action-patterns-core.md](references/action-patterns-core.md) β€” Variables, control flow, expressions
- [action-patterns-data.md](references/action-patterns-data.md) β€” Array transforms, HTTP, parsing
- [action-patterns-connectors.md](references/action-patterns-connectors.md) β€” SharePoint, Outlook, Teams, Approvals

```python
definition = {
    "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
    "contentVersion": "1.0.0.0",
    "triggers": { ... },   # see trigger-types.md / build-patterns.md
    "actions": { ... }     # see ACTION-PATTERNS-*.md / build-patterns.md
}
```

> See [build-patterns.md](references/build-patterns.md) for complete, ready-to-use
> flow definitions covering Recurrence+SharePoint+Teams, HTTP triggers, and more.

### Discover connector operations before guessing JSON

For connector-backed triggers/actions, prefer the live connector describer over
hand-written shapes. It can return authored hints, canonical examples, variant
keys, inputs/outputs, and dynamic metadata pointers.

```python
# Search across connectors when you know the user's intent but not the API.
matches = mcp("describe_live_connector",
    environmentName=ENV,
    search="send email",
    top=5)

# Describe a specific operation before copying an exampleDefinition.
op = mcp("describe_live_connector",
    environmentName=ENV,
    connectorName="shared_office365",
    operationId="SendEmailV2")
print(op.get("hint"))
```

When an operation has multiple authored variants, request the variant the flow
needs:

```python
teams_chat = mcp("describe_live_connector",
    environmentName=ENV,
    connectorName="shared_teams",
    operationId="PostMessageToConversation",
    variant="flowbot_chat")
```

When the operation description says a parameter has dynamic options or dynamic
properties, call the indicated next tool:

```python
sp_op = mcp("describe_live_connector",
    environmentName=ENV,
    connectorName="shared_sharepointonline",
    operationId="GetItems")

sites = mcp("get_live_dynamic_options",
    environmentName=ENV,
    connectorName="shared_sharepointonline",
    connectionName=conn_map["shared_sharepointonline"],
    operationId="GetItems",
    parameterName="dataset",
    dynamicMetadata=sp_op["dynamicParameters"]["dataset"])

fields = mcp("get_live_dynamic_properties",
    environmentName=ENV,
    connectorName="shared_sharepointonline",
    connectionName=conn_map["shared_sharepointonline"],
    operationId="GetItems",
    parameterName="item",
    parameters={"dataset": "<site-url>", "table": "<list-id>"},
    dynamicMetadata=sp_op["dynamicProperties"]["item"])
```

Use dynamic options for dropdown IDs such as SharePoint sites/lists and Teams
teams/channels. Use dynamic properties for schema/field shapes such as
SharePoint list item columns.

---

## 4. Deploy (Create or Update)

`update_live_flow` handles both creation and updates in a single tool.

### Create a new flow (no existing flow)

Omit `flowName` β€” the server generates a new GUID and creates via PUT:

```python
definition["description"] = "Weekly SharePoint β†’ Teams notification flow, built by agent"

result = mcp("update_live_flow",
    environmentName=ENV,
    # flowName omitted β†’ creates a new flow
    definition=definition,
    connectionReferences=connection_references,
    displayName="Overdue Invoice Notifications"
)

if result.get("error") is not None:
    print("Create failed:", result["error"])
else:
    # Capture the new flow ID for subsequent steps
    FLOW_ID = result["created"]
    print(f"βœ… Flow created: {FLOW_ID}")
```

### Update an existing flow

Provide `flowName` to PATCH:

```python
definition["description"] = (
    "Updated by agent on " + __import__('datetime').datetime.utcnow().isoformat()
)

result = mcp("update_live_flow",
    environmentName=ENV,
    flowName=FLOW_ID,
    definition=definition,
    connectionReferences=connection_references,
    displayName="My Updated Flow"
)

if result.get("error") is not None:
    print("Update failed:", result["error"])
else:
    print("Update succeeded:", result)
```

> ⚠️ `update_live_flow` always returns an `error` key.
> `null` (Python `None`) means success β€” do not treat the presence of the key as failure.
>
> ⚠️ Flow description lives at `definition["description"]`. The current server
> appends `#flowstudio-mcp` for usage tracking. Do not pass a top-level
> `description` argument unless `tool_search` shows one in the active schema.

### Common deployment errors

| Error message (contains) | Cause | Fix |
|---|---|---|
| `missing from connectionReferences` | An action's `host.connectionName` references a key that doesn't exist in the `connectionReferences` map | Ensure `host.connectionName` uses the **key** from `connectionReferences` (e.g. `shared_teams`), not the raw GUID |
| `ConnectionAuthorizationFailed` / 403 | The connection GUID belongs to another user or is not authorized | Re-run Step 2a and use a connection owned by the current `x-api-key` user |
| `InvalidTemplate` / `InvalidDefinition` | Syntax error in the definition JSON | Check `runAfter` chains, expression syntax, and action type spelling |
| `ConnectionNotConfigured` | A connector action exists but the connection GUID is invalid or expired | Re-check `list_live_connections` for a fresh GUID |

---

## 5. Verify the Deployment

```python
check = mcp("get_live_flow", environmentName=ENV, flowName=FLOW_ID)

# Confirm state
print("State:", check["properties"]["state"])  # Should be "Started"
# If state is "Stopped", use set_live_flow_state β€” NOT update_live_flow
# mcp("set_live_flow_state", environmentName=ENV, flowName=FLOW_ID, state="Started")

# Confirm the action we added is there
acts = check["properties"]["definition"]["actions"]
print("Actions:", list(acts.keys()))
```

---

## 6. Test the Flow

> **MANDATORY**: Before triggering any test run, **ask the user for confirmation**.
> Running a flow has real side effects β€” it may send emails, post Teams messages,
> write to SharePoint, start approvals, or call external APIs. Explain what the
> flow will do and wait for explicit approval before calling `trigger_live_flow`
> or `resubmit_live_flow_run`.

### Updated flows (have prior runs) β€” ANY trigger type

> **Use `resubmit_live_flow_run` first.** It works for EVERY trigger type β€”
> Recurrence, SharePoint, connector webhooks, Button, and HTTP. It replays
> the original trigger payload. Do NOT ask the user to manually trigger the
> flow or wait for the next scheduled run.

```python
runs = mcp("get_live_flow_runs", environmentName=ENV, flowName=FLOW_ID, top=1)
if runs:
    # Works for Recurrence, SharePoint, connector triggers β€” not just HTTP
    result = mcp("resubmit_live_flow_run",
        environmentName=ENV, flowName=FLOW_ID, runName=runs[0]["name"])
    print(result)   # {"resubmitted": true, "triggerName": "..."}
```

### HTTP-triggered flows β€” custom test payload

Only use `trigger_live_flow` when you need to send a **different** payload
than the original run. For verifying a fix, `resubmit_live_flow_run` is
better because it uses the exact data that caused the failure.

```python
defn = mcp("get_live_flow", environmentName=ENV, flowName=FLOW_ID)
triggers = defn["properties"]["definition"]["triggers"]
manual = next(iter(triggers.values()))
print("Expected body:", manual.get("inputs", {}).get("schema"))

result = mcp("trigger_live_flow",
    environmentName=ENV, flowName=FLOW_ID,
    body={"name": "Test", "value": 1})
print(f"Status: {result['responseStatus']}")
```

### Brand-new non-HTTP flows (Recurrence, connector triggers, etc.)

A brand-new Recurrence or connector-triggered flow has **no prior runs** to
resubmit and no HTTP endpoint to call. This is the ONLY scenario where you
need the temporary HTTP trigger approach below. **Deploy with a temporary
HTTP trigger first, test the actions, then swap to the production trigger.**

Compact recipe:

```python
production_trigger = definition["triggers"]
definition["triggers"] = {
    "manual": {"type": "Request", "kind": "Http", "inputs": {"schema": {}}}
}

result = mcp("update_live_flow",
    environmentName=ENV,
    flowName=FLOW_ID,       # omit if creating new
    definition=definition,
    connectionReferences=connection_references,
    displayName="Overdue Invoice Notifications")
FLOW_ID = FLOW_ID or result["created"]

test = mcp("trigger_live_flow", environmentName=ENV, flowName=FLOW_ID,
           body={"sample": "payload"})
runs = mcp("get_live_flow_runs", environmentName=ENV, flowName=FLOW_ID, top=1)

if runs[0]["status"] == "Failed":
    err = mcp("get_live_flow_run_error",
        environmentName=ENV, flowName=FLOW_ID, runName=runs[0]["name"])
    raise Exception(err["failedActions"][-1])

definition["triggers"] = production_trigger
mcp("update_live_flow",
    environmentName=ENV,
    flowName=FLOW_ID,
    definition=definition,
    connectionReferences=connection_references)
```

The trigger is only the entry point; testing through HTTP still exercises the
same actions. If actions use `triggerBody()` or `triggerOutputs()`, pass a
representative `trigger_live_flow.body` shaped like the production trigger
payload.

---

## Gotchas

| Mistake | Consequence | Prevention |
|---|---|---|
| Missing `connectionReferences` in deploy | 400 "Supply connectionReferences" | Always call `list_live_connections` first |
| `"operationOptions"` missing on Foreach | Parallel execution, race conditions on writes | Always add `"Sequential"` |
| `union(old_data, new_data)` | Old values override new (first-wins) | Use `union(new_data, old_data)` |
| `split()` on potentially-null string | `InvalidTemplate` crash | Wrap with `coalesce(field, '')` |
| Checking `result["error"]` exists | Always present; true error is `!= null` | Use `result.get("error") is not None` |
| Flow deployed but state is "Stopped" | Flow won't run on schedule | Call `set_live_flow_state` with `state: "Started"` β€” do **not** use `update_live_flow` for state changes |
| Teams "Chat with Flow bot" recipient as object | 400 `GraphUserDetailNotFound` | Use plain string with trailing semicolon (see below) |
| Copilot/Skills flow not in a solution | Copilot Studio may not discover it as an agent tool | After deploy, call `add_live_flow_to_solution` with the target `solutionId` |
| Button/Skills trigger used for MCP testing | MCP cannot directly fire the production trigger | Test the same actions through a temporary HTTP twin, then swap the trigger back |
| Connector action missing `metadata.operationMetadataId` | Designer/run-only UI can behave inconsistently | Preserve existing IDs; add stable GUIDs for new connector actions |
| Placeholder Excel `scriptId` | Dynamic validation fails at save time | Resolve the real Office Script ID before deploying |
| SharePoint `PatchItem` omits required fields | Save can fail even if the field is not changing | Echo unchanged required fields such as `item/Title` |
| Copilot Studio connector calls a draft agent | Connector invocation can fail or hit stale behavior | Publish the agent before testing/resubmitting the flow |

### Teams `PostMessageToConversation` β€” Recipient Formats

The `body/recipient` parameter format depends on the `location` value:

| Location | `body/recipient` format | Example |
|---|---|---|
| **Chat with Flow bot** | Plain email string with **trailing semicolon** | `"user@contoso.com;"` |
| **Channel** | Object with `groupId` and `channelId` | `{"groupId": "...", "channelId": "..."}` |

> **Common mistake**: passing `{"to": "user@contoso.com"}` for "Chat with Flow bot"
> returns a 400 `GraphUserDetailNotFound` error. The API expects a plain string.

---

## Reference Files

- [flow-schema.md](references/flow-schema.md) β€” Full flow definition JSON schema
- [trigger-types.md](references/trigger-types.md) β€” Trigger type templates
- [action-patterns-core.md](references/action-patterns-core.md) β€” Variables, control flow, expressions
- [action-patterns-data.md](references/action-patterns-data.md) β€” Array transforms, HTTP, parsing
- [action-patterns-connectors.md](references/action-patterns-connectors.md) β€” SharePoint, Outlook, Teams, Approvals
- [build-patterns.md](references/build-patterns.md) β€” Complete flow definition templates (Recurrence+SP+Teams, HTTP trigger)

## Related Skills

- `flowstudio-power-automate-mcp` β€” Core connection setup and tool reference
- `flowstudio-power-automate-debug` β€” Debug failing flows after deployment
references/
action-patterns-connectors.md 17.9 KB
# 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"
    }
  }
}
```

> `PatchItem` can validate required SharePoint columns even when you are not
> changing those fields. Echo unchanged required fields from the trigger or a
> prior Get Item action, for example `item/Title`, and use internal field names.

---

### 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).
> Keep the connector-specific parameter names exactly as shown:
> `parameters/method`, `parameters/uri`, `parameters/headers`, and
> `parameters/body`. The body is a JSON string, and `parameters/uri` is relative
> to the SharePoint `dataset`.

---

### 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`.

---

## Excel Online

### Excel β€” Run Office Script

Office Script actions require real workbook and script identifiers at save time.
Do not deploy placeholder `scriptId` values; `update_live_flow` can fail during
dynamic operation validation even before a test run exists.

Use `describe_live_connector` or `get_live_dynamic_options` when available, or
ask the user for the workbook and script if they are not discoverable. If a real
`scriptId` still cannot be resolved, ask the user to add the Run script action
once in the designer, then read the flow definition and preserve the resolved
parameters.

---

## 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)`

---

## Copilot Studio

### Copilot Studio β€” Invoke Agent

When using the Copilot Studio connector, publish the agent before running the
flow. Draft/test agents can exist in the studio canvas but still be unavailable
or stale through the flow connector endpoint.

If a connector action fails with an unavailable-agent or endpoint-style error,
publish the agent, wait briefly for propagation, then resubmit the same flow run
before changing the flow definition.

---

## 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.
action-patterns-core.md 16.2 KB
# 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: `5–10` for external API calls (respect rate limits),
> `20–50` 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.

---

### Agent Retry Loop

When a flow calls an AI or Copilot-style agent until it reaches a terminal
outcome, keep the loop state explicit:

- Initialize variables such as `agentStatus`, `attempt`, and `finalPayload`
  before the `Until`.
- Inside the loop, call the agent, validate the response, update the status, and
  delay/retry only when the status is non-terminal.
- Put final dispatch actions such as email, SharePoint update, or Teams post
  after the loop so retries do not duplicate side effects.
- If the platform rejects a complex `Switch` nested inside `Until`, keep the
  loop body to simple validation and state updates, then route with `Switch`
  after the loop.

---

### 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)
```

### Unsupported / Risky Expression Assumptions

Power Automate expressions are Workflow Definition Language, not JavaScript.
These patterns often look plausible but do not deploy or do not behave as agents
expect:

| Goal | Avoid | Use instead |
|---|---|---|
| Build an object inline | `createObject(...)` | A Compose action with a JSON object literal |
| Transform an array inline | `select(...)` inside an expression | Data Operations `Select` action |
| Filter an array inline | `filter(...)` inside an expression | Data Operations `Filter array` action |
| Find an array item index | `indexOf(array, item)` | Foreach with a counter variable, or build a keyed object map |

### 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.
action-patterns-data.md 18.5 KB
# 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"]`

For Cartesian products, iterate `range(0, mul(rowCount, colCount))` and derive
indexes with `div(item(), colCount)` and `mod(item(), colCount)`.

---

### 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']`

> 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.

**Insert/update/delete sync recipe:**

1. `Select_Dest_Keys` from destination rows.
2. `Filter_To_Insert`: source rows whose key is not in destination keys.
3. `Filter_Already_Exists`: source rows whose key is in destination keys.
4. For each compared field, run `Filter_<Field>_Changed`; combine them with
   `union()` into `Union_Changed`.
5. `Select_Changed_Keys` from `Union_Changed`, then filter destination rows to
   only those keys before updating.
6. `Select_Source_Keys`, then `Filter_To_Delete` destination rows whose key is
   not in source keys.

This changes O(n x m) nested loops to O(n + m) set operations and helps avoid
Power Automate's 100k-action run limit.

---

### 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.

Notes: `Detect_Line_Ending` handles CRLF/LF/CR. Dynamic keys in `Select` require
`@{...}` interpolation. This simple pattern does not safely parse quoted fields
with embedded delimiters; for those, use a dedicated parser or custom action.

---

### 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.
build-patterns.md 2.7 KB
# 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']`
flow-schema.md 5.1 KB
# 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"}`) |
| `metadata` | Designer metadata such as `operationMetadataId` |

#### Designer Metadata

For existing connector actions, preserve `metadata.operationMetadataId` when you
edit the definition. For new connector actions or Skills/HTTP response actions,
add a stable GUID and keep it stable across updates. Do not regenerate these IDs
on every deploy; the designer and some run-only surfaces use them to keep action
identity consistent.

#### `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": {}
}
```
trigger-types.md 5.3 KB
# 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.

---

## Manual (Copilot Studio Skills)

Use the Skills trigger when the flow is meant to be called by a Copilot Studio
agent tool. Keep the trigger schema explicit so the agent receives predictable
input names and types.

```json
"manual": {
  "type": "Request",
  "kind": "Skills",
  "inputs": {
    "schema": {
      "type": "object",
      "properties": {
        "itemId": { "type": "string" },
        "notes": { "type": "string" }
      },
      "required": ["itemId"]
    }
  },
  "metadata": {
    "operationMetadataId": "<stable-guid>"
  }
}
```

After deploying a production Skills-triggered flow, call
`add_live_flow_to_solution` with the target `solutionId`; Copilot Studio agent
tool discovery expects the flow to be solution-aware. For MCP-driven testing,
use a temporary HTTP twin with the same actions and payload shape, then restore
the Skills trigger after the actions are verified.

---

## 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')"
  }
}
```

License (MIT)

View full license text
MIT License

Copyright GitHub, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.