Skip to content
October 22, 2026 • Senior (5+ years) Career Guide

Designing an Autonomous IT Triage Agent in Copilot Studio: Intune Remediation via Power Automate

By Last updated

AB-100 study design: a Copilot Studio triage agent that requests Intune remediations through Power Automate, with approval gates and synthetic endpoint tests.

Free PDF

Top 10 Intune and Autopilot Enrollment Errors: Causes and Fixes

A free cheat sheet covering ten enrollment errors, each with the symptom, the most likely cause, the fix, and a link to the full guide.

  • Ten real error codes and failures, including 0x80180014, 0x8018002b, 0x801c03f3, 0x800705b4, the ODJConnector logon failure, and the Win32 detection error 0x87d1041c.
  • Every cause and fix checked against Microsoft's own documentation.
  • A link from each entry to the full zakitpro.com article.

You get the free PDF. By subscribing you also agree to receive occasional emails from zakitpro about Intune and Autopilot troubleshooting. Emails are sent through MailerLite. You can unsubscribe anytime with the link in every email. See the privacy page for how your email is handled.

Step-by-step guide

Designing an Autonomous IT Triage Agent in Copilot Studio: Intune Remediation via Power Automate

1. Study log: why the write path decides the design

This entry designs an autonomous IT triage agent in Copilot Studio. On a schedule, it finds noncompliant Windows devices in Intune, picks a remediation script from an allowlist and asks a human to approve the run. Power Automate does the work. It’s the ZK-03 case from the AB-100 trade-off matrix: noncompliant-device triage that posts to a Teams channel on a schedule.

I haven’t deployed this agent to a tenant, which is why the title says designing. Everything below is a design built from Microsoft Learn, plus a PowerShell script that validates the design and runs 11 synthetic scenarios against an in-memory mock. No real device, tenant or Graph call was involved.

The reading changed my first sketch. I planned one agent flow that asked for approval and then ran the remediation. That can’t work. An agent flow has to respond to the agent within the 100-second action limit,[4] and a human approval can take hours. So the design splits into two flows, and the agent never holds the write.

Default approachAI-hardened approach
Give the agent a tool that runs the remediation.Agent tools are read-only. The only write, initiateOnDemandProactiveRemediation, runs in a separate flow after a human approves.[12]
Wait for the approval inside the agent’s flow.The agent flow writes a request row and responds. A second flow, started by the new Dataverse row, waits for the approval.[4][21]
Let the agent pass any script ID it finds.The flow refuses any script package that isn’t on an allowlist, whatever the agent asks for.
Run the scheduled agent with a highly privileged maker account.Event triggers can only use the maker’s credentials, and users of the agent can reach data through that authorization.[5] Give the agent’s account a read-only Intune role and keep the run permission on the approval flow’s account.
Trust a 204 as proof the fix ran.A 204 means Graph accepted the request.[12] An offline device doesn’t receive the action,[17] so check the run state later.[16]
Fire a new run every time the agent sees the device.One request per device inside the approval window, because Run remediation actions sent close together can overwrite each other.[17]

Blueprint note. As of October 22, 2026, Microsoft’s study guide publishes only the “Skills measured as of November 16, 2026” list, with Plan at 35–40%, Design at 30–35% and Deploy at 20–25%.[1] The exam page says the English exam updates on November 16 and localized versions about eight weeks later.[2] The change log marks “Design AI and agents for business solutions” as a minor change.[1] If you test before November 16, the October 14 weights in the previous entry apply. I map this entry to the November 16 objective names because that’s the only list Microsoft shows now. I don’t mix the two sets of weights.

2. Architecture and prerequisites

The design in one picture

  1. Recurrence trigger. The agent wakes on a schedule. Event triggers need generative orchestration turned on.[5]
  2. Read tools. The agent calls three read-only connector operations: list noncompliant devices, get one device and list remediation script packages.
  3. RequestRemediation agent flow (Flow A). The agent passes a device ID, a script package ID and a reason. Flow A validates them, checks the device, refuses duplicates, writes a Pending row to a Dataverse table and responds to the agent.
  4. ApproveAndRunRemediation flow (Flow B). The Dataverse trigger “When a row is added, modified or deleted” starts it.[21] It runs “Start and wait for an approval”,[19] re-checks the device, calls the remediation action, updates the row and posts the outcome with “Post message in a chat or channel”.[22]

The Dataverse table is the audit trail and the dedupe key. Every request, approval, refusal and failure ends up as a row status.

Prerequisites and licensing matrix

RequirementWhat Microsoft documents
Remediations licensingUsers of the devices need Windows Enterprise E3 or E5 (included in Microsoft 365 F3, E3 or E5), Windows Education A3 or A5, or Windows VDA per user.[17]
DeviceMicrosoft Entra joined or hybrid joined, and either MDM enrolled running Windows Enterprise, Professional or Education, or co-managed.[17]
On-demand runRun remediation (preview) targets a single Windows device. Remediations must already be configured, and the device must be online and able to reach Intune and WNS.[17]
Intune role for the runHelp Desk Operator, School Administrator, or a custom role with Remote tasks/Run Remediation plus device visibility such as Organization/Read.[18] During the public preview, the user also needs Organization: Read.[17]
Graph permission for the runDeviceManagementManagedDevices.PrivilegedOperations.All, delegated or application. The API is in beta, and the Graph API for Intune needs an active Intune license.[12]
Graph permissions for readsDeviceManagementManagedDevices.Read.All for devices,[13] DeviceManagementScripts.Read.All for script packages and run states.[15][16]
Copilot StudioREST API tools are in preview and need maker-level credentials and a Copilot Studio license.[7] Agent flows consume Copilot Studio capacity for each action, and test runs don’t.[3]
Event trigger billingEach trigger payload counts as a message. A recurrence every 10 minutes sends a message every 10 minutes.[5]
Environment settingsSolution-aware cloud flow sharing must be on for event triggers, and admins can block event triggers through data policies.[5]
ApprovalsPower Automate runs time out after 30 days, including pending approvals.[23] The connector allows 50 approval create requests per flow per 60 seconds.[19]

I didn’t verify custom connector licensing for this design. Check your Power Platform plan before you build.

Why delegated permissions and two accounts

Delegated permissions let an app act for a signed-in user, but the app “can’t access anything the signed-in user couldn’t access.”[24] Application permissions are highly privileged, and only Privileged Role Administrator and Global Administrator can consent to them.[24] So I use delegated permissions with two connection accounts:

  • Agent connection. The account behind the agent’s tools and the recurrence trigger. It gets a read-only Intune role. If a prompt ever steers the agent toward a write, this account doesn’t hold the Intune permission to run it.
  • Approval flow connection. The account behind Flow B’s remediation call. It gets Help Desk Operator or a custom role with Remote tasks/Run Remediation.[18] Use scope tags to limit which script packages it can see.[17]

3. Build steps and where each one fails

I haven’t run these steps in a tenant. They’re the order I’d build in, with the failure point Microsoft’s docs warn about.

  1. Confirm Remediations works without the agent. Pick or create the script packages. The built-in packages “Update stale Group Policies” and “Restart Office Click-to-run service” only need assigning.[17] A remediation script runs only when the detection script exits with exit 1.[17] Don’t put reboot commands in either script.[17] Run one package by hand from the device’s Run remediation (preview) action.[17] Failure point: the device is offline or can’t reach WNS, so it never receives the action.[17]
  2. Register the app and create the custom connector. Import the OpenAPI file in section 4. A custom connector needs OpenAPI 2.0, under 1 MB, and OpenAPI 3.0 isn’t supported.[8] On the Security tab, pick OAuth 2.0 with Azure Active Directory as the identity provider.[10] OAuth 2.0 connectors use a per-connector redirect URI, so add that URI to the app registration.[26] Failure point: Microsoft’s walkthrough sets the Resource URL for Azure Resource Manager.[10] This connector calls Graph, so set the Resource URL for Graph. I haven’t tested that setting.
  3. Hide what the agent shouldn’t touch. Parameters marked internal are hidden from the user, and internal required parameters must have a default.[9] The connector fixes $filter to complianceState eq 'noncompliant' and fixes $select the same way. Failure point: an internal required parameter without a default.[9]
  4. Create the Dataverse table. Columns: request ID, managed device ID, script package ID, reason, status, approver and created time. Flow A uses “List rows” and “Add a new row”. Flow B uses “Update a row”.[21]
  5. Build Flow A as an agent flow. Use the “When an agent calls the flow” trigger and the “Respond to the agent” action. Set Asynchronous response to Off.[4] Flow A never calls an approval. Failure point: a slow device lookup pushes the run past 100 seconds.[4]
  6. Build Flow B. Trigger on new rows in the table.[21] Assign the approval with semicolon-separated email addresses, UPNs or Entra user IDs.[19] For Basic and Await all approval types, the responses “Approve” and “Reject” are case-sensitive, so match them exactly in the condition.[19] The flow creator always shows in the approval details, which Microsoft does to prevent sender spoofing.[19] After approval, re-check the device, call the action and expect 204.[12] Failure point: an approval that sits for 30 days times out with the run.[23] My design expires requests after 24 hours.
  7. Build the agent. Turn on generative orchestration.[5] On the Tools page, add the custom connector’s three read operations one tool at a time, then add Flow A.[11] Leave the remediation action out. Microsoft’s REST API tool guide makes the same point: you “might not want the agent’s users to have the ability to execute every action the API generally offers.”[7] For chat use, set Ask the end user before running to Yes on Flow A. It defaults to No.[6] Failure point: the agent asks a user for credentials during a scheduled run and stops. Autonomous runs need working maker authentication on every action.[5]
  8. Add the recurrence trigger. Keep the batch small. Microsoft says agents run more reliably when calling fewer than 15 actions or topics consecutively,[5] so the connector caps $top at 10. Failure point: a short interval burns messages.[5]
  9. Test before you publish. Test chat and flow designer runs don’t consume Copilot Studio capacity.[3] Run the script in section 4 against your own design file first.

On prompt injection: Copilot Studio custom agents include protection against user prompt injection (UPIA) and cross-domain prompt injection (XPIA).[25] I don’t rely on it here. A device name or reason string could carry instructions, so Flow A checks every input against fixed rules, and a person approves every write.

4. Engineering deliverable: connector, flow design and synthetic tests

The OpenAPI 2.0 connector

Five Graph beta operations. Four are reads. InitiateRemediation is the only write, marked advanced and meant for Flow B only. The request body matches the documented scriptPolicyId parameter.[12] The complianceState values and the $filter support come from the managedDevice reference,[14] and the script package and run state fields come from their list operations.[15][16] I took device property names from the v1.0 reference and call the beta endpoint so one connector covers the beta-only action. Beta APIs can change.[12] swagger-cli 4.0.4 validated the file.

{
  "swagger": "2.0",
  "info": {
    "title": "ZK Intune Triage (Graph beta subset)",
    "description": "Five Microsoft Graph beta operations for noncompliant-device triage and on-demand Intune remediation. Study design, not a Microsoft connector.",
    "version": "1.0.0"
  },
  "host": "graph.microsoft.com",
  "basePath": "/beta",
  "schemes": ["https"],
  "consumes": ["application/json"],
  "produces": ["application/json"],
  "paths": {
    "/deviceManagement/managedDevices": {
      "get": {
        "operationId": "ListNoncompliantDevices",
        "summary": "List noncompliant managed devices",
        "description": "Read-only. Returns managed devices whose complianceState is noncompliant.",
        "x-ms-visibility": "important",
        "parameters": [
          { "name": "$filter", "in": "query", "required": true, "type": "string", "default": "complianceState eq 'noncompliant'", "x-ms-visibility": "internal", "x-ms-summary": "Filter" },
          { "name": "$select", "in": "query", "required": true, "type": "string", "default": "id,deviceName,operatingSystem,complianceState,lastSyncDateTime,azureADDeviceId", "x-ms-visibility": "internal", "x-ms-summary": "Select" },
          { "name": "$top", "in": "query", "required": false, "type": "integer", "minimum": 1, "maximum": 10, "x-ms-summary": "Maximum devices" }
        ],
        "responses": {
          "200": { "description": "OK", "schema": { "$ref": "#/definitions/ManagedDeviceList" } }
        }
      }
    },
    "/deviceManagement/managedDevices/{managedDeviceId}": {
      "get": {
        "operationId": "GetManagedDevice",
        "summary": "Get one managed device",
        "description": "Read-only. Returns triage fields for one Intune managed device.",
        "x-ms-visibility": "important",
        "parameters": [
          { "$ref": "#/parameters/managedDeviceId" },
          { "name": "$select", "in": "query", "required": true, "type": "string", "default": "id,deviceName,operatingSystem,complianceState,lastSyncDateTime,azureADDeviceId", "x-ms-visibility": "internal", "x-ms-summary": "Select" }
        ],
        "responses": {
          "200": { "description": "OK", "schema": { "$ref": "#/definitions/ManagedDevice" } },
          "404": { "description": "Device not found" }
        }
      }
    },
    "/deviceManagement/deviceHealthScripts": {
      "get": {
        "operationId": "ListRemediationScripts",
        "summary": "List remediation script packages",
        "description": "Read-only. Returns Remediations script packages (deviceHealthScripts).",
        "x-ms-visibility": "important",
        "parameters": [
          { "name": "$select", "in": "query", "required": true, "type": "string", "default": "id,displayName,publisher", "x-ms-visibility": "internal", "x-ms-summary": "Select" }
        ],
        "responses": {
          "200": { "description": "OK", "schema": { "$ref": "#/definitions/DeviceHealthScriptList" } }
        }
      }
    },
    "/deviceManagement/deviceHealthScripts/{deviceHealthScriptId}/deviceRunStates": {
      "get": {
        "operationId": "ListRemediationRunStates",
        "summary": "List run states for a script package",
        "description": "Read-only. Returns per-device detection and remediation states for one script package.",
        "x-ms-visibility": "advanced",
        "parameters": [
          { "name": "deviceHealthScriptId", "in": "path", "required": true, "type": "string", "x-ms-summary": "Script package ID" }
        ],
        "responses": {
          "200": { "description": "OK", "schema": { "$ref": "#/definitions/DeviceHealthScriptDeviceStateList" } }
        }
      }
    },
    "/deviceManagement/managedDevices/{managedDeviceId}/initiateOnDemandProactiveRemediation": {
      "post": {
        "operationId": "InitiateRemediation",
        "summary": "Run a remediation on one device",
        "description": "WRITE. Issues the Run remediation device action. Call only from the approval flow, never as an agent tool.",
        "x-ms-visibility": "advanced",
        "parameters": [
          { "$ref": "#/parameters/managedDeviceId" },
          { "name": "body", "in": "body", "required": true, "schema": { "$ref": "#/definitions/RemediationRequest" } }
        ],
        "responses": {
          "204": { "description": "No Content" }
        }
      }
    }
  },
  "parameters": {
    "managedDeviceId": { "name": "managedDeviceId", "in": "path", "required": true, "type": "string", "x-ms-summary": "Managed device ID" }
  },
  "definitions": {
    "ManagedDevice": {
      "type": "object",
      "properties": {
        "id": { "type": "string" },
        "deviceName": { "type": "string" },
        "operatingSystem": { "type": "string" },
        "complianceState": { "type": "string", "enum": ["unknown", "compliant", "noncompliant", "conflict", "error", "inGracePeriod", "configManager"] },
        "lastSyncDateTime": { "type": "string", "format": "date-time" },
        "azureADDeviceId": { "type": "string" }
      }
    },
    "ManagedDeviceList": {
      "type": "object",
      "properties": { "value": { "type": "array", "items": { "$ref": "#/definitions/ManagedDevice" } } }
    },
    "DeviceHealthScript": {
      "type": "object",
      "properties": { "id": { "type": "string" }, "displayName": { "type": "string" }, "publisher": { "type": "string" } }
    },
    "DeviceHealthScriptList": {
      "type": "object",
      "properties": { "value": { "type": "array", "items": { "$ref": "#/definitions/DeviceHealthScript" } } }
    },
    "DeviceHealthScriptDeviceState": {
      "type": "object",
      "properties": {
        "id": { "type": "string" },
        "detectionState": { "type": "string" },
        "remediationState": { "type": "string" },
        "lastStateUpdateDateTime": { "type": "string", "format": "date-time" }
      }
    },
    "DeviceHealthScriptDeviceStateList": {
      "type": "object",
      "properties": { "value": { "type": "array", "items": { "$ref": "#/definitions/DeviceHealthScriptDeviceState" } } }
    },
    "RemediationRequest": {
      "type": "object",
      "required": ["scriptPolicyId"],
      "properties": { "scriptPolicyId": { "type": "string" } }
    }
  }
}

The flow design file

This is a design spec in my own schema, not an importable Power Automate export. Each step names the documented operation it maps to: Dataverse ListRecords, CreateRecord, UpdateOnlyRecord and the SubscribeWebhookTrigger trigger,[21] Approvals StartAndWaitForAnApproval,[19] and Teams PostMessageToConversation.[22] The policy values are synthetic.

{
  "schemaVersion": 1,
  "connector": "triage-connector.swagger.json",
  "policy": {
    "allowedScriptPolicyIds": ["11111111-aaaa-4bbb-8ccc-000000000001", "11111111-aaaa-4bbb-8ccc-000000000002"],
    "approvers": "endpoint-oncall-1@contoso.example;endpoint-oncall-2@contoso.example",
    "requestTtlHours": 24,
    "maxOpenRequestsPerDevice": 1,
    "allowedOperatingSystems": ["Windows"]
  },
  "agentTools": ["ListNoncompliantDevices", "GetManagedDevice", "ListRemediationScripts", "RequestRemediation"],
  "flows": [
    {
      "name": "RequestRemediation",
      "kind": "agent-flow",
      "trigger": { "name": "When an agent calls the flow", "inputs": ["managedDeviceId", "scriptPolicyId", "reason"] },
      "steps": [
        { "id": "A1", "type": "validate", "rule": "managedDeviceId and scriptPolicyId are GUIDs; reason is 10-500 characters" },
        { "id": "A2", "type": "validate", "rule": "scriptPolicyId is in policy.allowedScriptPolicyIds" },
        { "id": "A3", "type": "connector", "operationId": "GetManagedDevice", "rule": "device exists, operatingSystem in policy.allowedOperatingSystems, complianceState is noncompliant" },
        { "id": "A4", "type": "dataverse", "operationId": "ListRecords", "rule": "no Pending, Approved or Remediated request for this device younger than policy.requestTtlHours" },
        { "id": "A5", "type": "dataverse", "operationId": "CreateRecord", "rule": "write request row with status Pending" },
        { "id": "A6", "type": "respond", "name": "Respond to the agent", "rule": "return requestId and status; Asynchronous response Off" }
      ]
    },
    {
      "name": "ApproveAndRunRemediation",
      "kind": "agent-flow",
      "trigger": { "name": "When a row is added, modified or deleted", "operationId": "SubscribeWebhookTrigger", "rule": "change type Added, table Remediation requests" },
      "steps": [
        { "id": "B1", "type": "approval", "operationId": "StartAndWaitForAnApproval", "rule": "Approve/Reject approval that completes on the first response, assigned to policy.approvers" },
        { "id": "B2", "type": "condition", "rule": "outcome is Approve and response arrived within policy.requestTtlHours of the request" },
        { "id": "B3", "type": "connector", "operationId": "GetManagedDevice", "rule": "re-check: device still noncompliant" },
        { "id": "B4", "type": "connector", "operationId": "InitiateRemediation", "rule": "expect 204" },
        { "id": "B5", "type": "dataverse", "operationId": "UpdateOnlyRecord", "rule": "set status Remediated, Rejected, Expired, Skipped or Failed, with approver and reason" },
        { "id": "B6", "type": "teams", "operationId": "PostMessageToConversation", "rule": "post the outcome to the endpoint channel" }
      ]
    }
  ],
  "knownOperations": {
    "dataverse": ["ListRecords", "CreateRecord", "UpdateOnlyRecord", "SubscribeWebhookTrigger"],
    "approvals": ["StartAndWaitForAnApproval"],
    "teams": ["PostMessageToConversation"]
  }
}

The synthetic scenarios

Seven synthetic devices and 11 scenarios. The device and script IDs are made up.

{
  "schemaVersion": 1,
  "devices": [
    { "id": "d0000000-0000-4000-8000-000000000001", "deviceName": "ZK-LAB-W11-01", "operatingSystem": "Windows", "complianceState": "noncompliant" },
    { "id": "d0000000-0000-4000-8000-000000000002", "deviceName": "ZK-LAB-W11-02", "operatingSystem": "Windows", "complianceState": "noncompliant" },
    { "id": "d0000000-0000-4000-8000-000000000003", "deviceName": "ZK-LAB-W11-03", "operatingSystem": "Windows", "complianceState": "compliant" },
    { "id": "d0000000-0000-4000-8000-000000000004", "deviceName": "ZK-LAB-IOS-01", "operatingSystem": "iOS", "complianceState": "noncompliant" },
    { "id": "d0000000-0000-4000-8000-000000000005", "deviceName": "ZK-LAB-W11-05", "operatingSystem": "Windows", "complianceState": "noncompliant" },
    { "id": "d0000000-0000-4000-8000-000000000006", "deviceName": "ZK-LAB-W11-06", "operatingSystem": "Windows", "complianceState": "noncompliant" },
    { "id": "d0000000-0000-4000-8000-000000000007", "deviceName": "ZK-LAB-W11-07", "operatingSystem": "Windows", "complianceState": "noncompliant" }
  ],
  "scenarios": [
    { "id": "S01", "device": "d0000000-0000-4000-8000-000000000001", "script": "11111111-aaaa-4bbb-8ccc-000000000001", "reason": "Detection reports stale Group Policy", "decision": "Approve", "responseAfterHours": 1, "expect": "Remediated" },
    { "id": "S02", "device": "d0000000-0000-4000-8000-000000000002", "script": "11111111-aaaa-4bbb-8ccc-000000000002", "reason": "Office Click-to-run service stopped", "decision": "Reject", "responseAfterHours": 1, "expect": "Rejected" },
    { "id": "S03", "device": "d0000000-0000-4000-8000-000000000005", "script": "99999999-aaaa-4bbb-8ccc-000000000009", "reason": "Script that is not on the allowlist", "expect": "Refused:A2" },
    { "id": "S04", "device": "d0000000-0000-4000-8000-000000000003", "script": "11111111-aaaa-4bbb-8ccc-000000000001", "reason": "Device is already compliant", "expect": "Refused:A3" },
    { "id": "S05", "device": "d0000000-0000-4000-8000-000000000004", "script": "11111111-aaaa-4bbb-8ccc-000000000001", "reason": "Remediations only target Windows", "expect": "Refused:A3" },
    { "id": "S06", "device": "d0000000-0000-4000-8000-000000000001", "script": "11111111-aaaa-4bbb-8ccc-000000000001", "reason": "Second request for the S01 device", "expect": "Refused:A4" },
    { "id": "S07", "device": "d0000000-0000-4000-8000-000000000005", "script": "11111111-aaaa-4bbb-8ccc-000000000001", "reason": "Approver answers after the TTL", "decision": "Approve", "responseAfterHours": 25, "expect": "Expired" },
    { "id": "S08", "device": "d0000000-0000-4000-8000-000000000006", "script": "11111111-aaaa-4bbb-8ccc-000000000001", "reason": "Device turns compliant while waiting", "decision": "Approve", "responseAfterHours": 2, "becomesCompliantBeforeRun": true, "expect": "Skipped" },
    { "id": "S09", "device": "d0000000-0000-4000-8000-000000000007", "script": "11111111-aaaa-4bbb-8ccc-000000000002", "reason": "Graph throttles the device action", "decision": "Approve", "responseAfterHours": 1, "postStatus": 429, "expect": "Failed" },
    { "id": "S10", "device": "not-a-guid", "script": "11111111-aaaa-4bbb-8ccc-000000000001", "reason": "Malformed device ID from the chat", "expect": "Refused:A1" },
    { "id": "S11", "device": "d0000000-0000-4000-8000-0000000000ff", "script": "11111111-aaaa-4bbb-8ccc-000000000001", "reason": "Device ID that Intune does not know", "expect": "Refused:A3" }
  ]
}

The test script

Test-TriageAgentDesign.ps1 validates the design and then replays the scenarios through both flows against a mock. It never calls Graph, Dataverse, Approvals or Teams.

Phase 1 checks the connector and the design together: OpenAPI 2.0, under 1 MB, internal required parameters have defaults, allowlist entries are GUIDs, at least two distinct approvers, a TTL between 1 and 168 hours, agent tools limited to GET operations or flows, every step maps to a known operation, and no approval step inside Flow A. It collects every error before it stops.

Phase 2 runs each scenario through Flow A (steps A1 to A5) and, for requests that get written, through Flow B (B1 to B5). It counts calls to the write operation per scenario.

Safety features: -WhatIf writes nothing. A manifest stores SHA256 hashes of the inputs and outputs. A second run with the same inputs reports no changes. Changed inputs or an edited output stop the run unless you pass -Force. -Rollback removes only files whose hash still matches, and audit.log records each write and removal as JSON lines.

What I tested, on Linux with PowerShell 7.4.6, all against synthetic input: -WhatIf reported 11 passing scenarios and created no output folder. A normal run passed all 11 and wrote test-results.json, ledger.json, manifest.json and audit.log. A second identical run reported no changes. After I edited test-results.json, a run without -Force stopped with an error, and -Force rebuilt the output. -Rollback -WhatIf removed nothing. After I edited ledger.json, -Rollback removed test-results.json, kept ledger.json with a warning and kept the manifest. A broken design with OpenAPI 3.0, a missing internal default, a non-GUID allowlist entry, one approver, the write operation as an agent tool, an unknown Dataverse operation and an approval inside Flow A failed with all seven errors and wrote nothing. Invalid JSON stopped the run. PSScriptAnalyzer 1.25.0 reported no findings.

The first run failed one scenario, S06. A second request for the S01 device went through because S01 had already moved to Remediated, and my duplicate check only looked at Pending and Approved rows. I added Remediated inside the TTL to the check, based on Microsoft’s warning that Run remediation actions sent close together can overwrite each other.[17]

Not tested: anything in a tenant. That includes the connector import, OAuth consent, the Graph calls, approvals, Dataverse, Teams posts, real latency against the 100-second limit and Copilot Studio billing.

#Requires -Version 7.2
<#
.SYNOPSIS
  Validates the AB-100 IT triage agent design (OpenAPI 2.0 connector + flow design)
  and runs synthetic endpoint tests against an in-memory mock of the five Graph operations.
.NOTES
  Study aid. Never calls Microsoft Graph, Dataverse, Approvals or Teams. All data is synthetic.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)] [string] $DesignPath,
    [Parameter(Mandatory)] [string] $ScenarioPath,
    [string] $OutputDirectory = './triage-test-output',
    [switch] $Force,
    [switch] $Rollback
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$GuidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
$manifestPath = Join-Path $OutputDirectory 'manifest.json'
$auditPath = Join-Path $OutputDirectory 'audit.log'

function Get-Sha256([string] $Path) { (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() }
function Write-Audit([string] $Action, [string] $Detail) {
    if (-not (Test-Path -LiteralPath $OutputDirectory)) { return }
    $line = [ordered]@{ utc = [DateTime]::UtcNow.ToString('o'); user = [Environment]::UserName; action = $Action; detail = $Detail }
    Add-Content -LiteralPath $auditPath -Value ($line | ConvertTo-Json -Compress)
}

# Rollback: remove only files this script wrote and nobody changed since.
if ($Rollback) {
    if (-not (Test-Path -LiteralPath $manifestPath)) { Write-Output "Nothing to roll back in $OutputDirectory."; return }
    $m = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
    $kept = 0
    foreach ($f in $m.files) {
        $p = Join-Path $OutputDirectory $f.name
        if (-not (Test-Path -LiteralPath $p)) { continue }
        if ((Get-Sha256 $p) -ne $f.sha256) { Write-Warning "Kept $($f.name): changed since this script wrote it."; $kept++; continue }
        if ($PSCmdlet.ShouldProcess($p, 'Remove generated file')) { Remove-Item -LiteralPath $p; Write-Audit 'remove' $f.name }
    }
    if ($kept -eq 0 -and $PSCmdlet.ShouldProcess($manifestPath, 'Remove manifest')) { Remove-Item -LiteralPath $manifestPath; Write-Audit 'remove' 'manifest.json' }
    return
}

# ---- Phase 1: static validation of the design and the connector ----
$errors = [System.Collections.Generic.List[string]]::new()
$design = Get-Content -LiteralPath $DesignPath -Raw | ConvertFrom-Json -AsHashtable
$swaggerPath = Join-Path (Split-Path -Parent (Resolve-Path -LiteralPath $DesignPath)) $design['connector']
$swaggerFile = Get-Item -LiteralPath $swaggerPath
$swagger = Get-Content -LiteralPath $swaggerPath -Raw | ConvertFrom-Json -AsHashtable

if ($design['schemaVersion'] -ne 1) { $errors.Add('Design schemaVersion must be 1.') }
if ($swagger['swagger'] -ne '2.0') { $errors.Add('Connector must be OpenAPI 2.0 ("swagger": "2.0").') }
if ($swaggerFile.Length -ge 1MB) { $errors.Add('Connector definition must be less than 1 MB.') }

$ops = @{}
foreach ($path in $swagger['paths'].Keys) {
    foreach ($verb in $swagger['paths'][$path].Keys) {
        $op = $swagger['paths'][$path][$verb]
        $ops[$op['operationId']] = @{ verb = $verb; path = $path; op = $op }
        foreach ($p in @($op['parameters'])) {
            if ($null -eq $p -or $p.ContainsKey('$ref')) { continue }
            if ($p['x-ms-visibility'] -eq 'internal' -and $p['required'] -and -not $p.ContainsKey('default')) {
                $errors.Add("$($op['operationId']): internal required parameter '$($p['name'])' needs a default.")
            }
        }
    }
}

$policy = $design['policy']
foreach ($id in @($policy['allowedScriptPolicyIds'])) { if ($id -notmatch $GuidPattern) { $errors.Add("Allowlisted script ID '$id' is not a GUID.") } }
if (@($policy['allowedScriptPolicyIds']).Count -eq 0) { $errors.Add('allowedScriptPolicyIds must not be empty.') }
$approvers = @(([string]$policy['approvers']).Split(';', [StringSplitOptions]::RemoveEmptyEntries).Trim())
if ($approvers.Count -lt 2) { $errors.Add('Assign at least two approvers.') }
if (@($approvers | Sort-Object -Unique).Count -ne $approvers.Count) { $errors.Add('Approver list has duplicates.') }
if ($policy['requestTtlHours'] -isnot [long] -and $policy['requestTtlHours'] -isnot [int]) { $errors.Add('requestTtlHours must be an integer.') }
elseif ($policy['requestTtlHours'] -lt 1 -or $policy['requestTtlHours'] -gt 168) { $errors.Add('requestTtlHours must be 1-168.') }

$flowNames = @($design['flows'] | ForEach-Object { $_['name'] })
foreach ($tool in @($design['agentTools'])) {
    if ($flowNames -contains $tool) { continue }
    if (-not $ops.ContainsKey($tool)) { $errors.Add("Agent tool '$tool' is not a connector operation or flow."); continue }
    if ($ops[$tool].verb -ne 'get') { $errors.Add("Agent tool '$tool' is a $($ops[$tool].verb.ToUpper()) operation. Only read operations may be agent tools.") }
}
$known = $design['knownOperations']
foreach ($flow in $design['flows']) {
    foreach ($s in $flow['steps']) {
        $oid = $s['operationId']
        switch ($s['type']) {
            'connector' { if (-not $ops.ContainsKey($oid)) { $errors.Add("$($s['id']): operation '$oid' not in connector.") } }
            'dataverse' { if ($oid -notin $known['dataverse']) { $errors.Add("$($s['id']): unknown Dataverse operation '$oid'.") } }
            'approval'  { if ($oid -notin $known['approvals']) { $errors.Add("$($s['id']): unknown Approvals operation '$oid'.") } }
            'teams'     { if ($oid -notin $known['teams']) { $errors.Add("$($s['id']): unknown Teams operation '$oid'.") } }
        }
    }
}
$agentFlow = $design['flows'] | Where-Object { $_['name'] -eq 'RequestRemediation' }
if (@($agentFlow['steps'] | Where-Object { $_['type'] -eq 'approval' }).Count -gt 0) {
    $errors.Add('RequestRemediation must not wait for an approval: agent flows must respond within 100 seconds.')
}
if ($errors.Count -gt 0) { throw ("Design validation failed:`n - " + ($errors -join "`n - ")) }

# ---- Phase 2: synthetic endpoint tests ----
$scn = Get-Content -LiteralPath $ScenarioPath -Raw | ConvertFrom-Json -AsHashtable
$devices = @{}; foreach ($d in $scn['devices']) { $devices[$d['id']] = $d.Clone() }
$calls = [System.Collections.Generic.List[object]]::new()
$ledger = [System.Collections.Generic.List[object]]::new()
$clock = [DateTimeOffset]::Parse('2026-10-22T15:00:00Z')

function Invoke-MockGraph([string] $OperationId, [string] $DeviceId, [int] $ForceStatus = 0) {
    $calls.Add([ordered]@{ op = $OperationId; device = $DeviceId })
    switch ($OperationId) {
        'GetManagedDevice' {
            if (-not $devices.ContainsKey($DeviceId)) { return @{ status = 404 } }
            return @{ status = 200; body = $devices[$DeviceId] }
        }
        'InitiateRemediation' {
            if ($ForceStatus) { return @{ status = $ForceStatus } }
            return @{ status = 204 }
        }
    }
}

function Invoke-RequestRemediation([hashtable] $s) {
    # A1: input validation
    if ($s['device'] -notmatch $GuidPattern -or $s['script'] -notmatch $GuidPattern) { return 'Refused:A1' }
    if ($s['reason'].Length -lt 10 -or $s['reason'].Length -gt 500) { return 'Refused:A1' }
    # A2: allowlist
    if ($s['script'] -notin $policy['allowedScriptPolicyIds']) { return 'Refused:A2' }
    # A3: device check through the read-only connector operation
    $r = Invoke-MockGraph 'GetManagedDevice' $s['device']
    if ($r.status -ne 200) { return 'Refused:A3' }
    if ($r.body['operatingSystem'] -notin $policy['allowedOperatingSystems'] -or $r.body['complianceState'] -ne 'noncompliant') { return 'Refused:A3' }
    # A4: idempotency. One open or just-run request per device within the TTL, because
    # Run remediation actions sent close together can overwrite each other.
    $open = @($ledger | Where-Object { $_.device -eq $s['device'] -and $_.status -in 'Pending', 'Approved', 'Remediated' -and ($clock - $_.created).TotalHours -lt $policy['requestTtlHours'] })
    if ($open.Count -ge $policy['maxOpenRequestsPerDevice']) { return 'Refused:A4' }
    # A5: write the request row (Dataverse stand-in)
    $row = [pscustomobject]@{ requestId = "REQ-$($s['id'])"; device = $s['device']; script = $s['script']; status = 'Pending'; created = $clock; approver = $null }
    $ledger.Add($row)
    return $row
}

function Invoke-ApproveAndRun($row, [hashtable] $s) {
    # B1/B2: approval outcome and TTL
    if ($s['decision'] -ne 'Approve') { $row.status = 'Rejected'; return }
    if ($s['responseAfterHours'] -ge $policy['requestTtlHours']) { $row.status = 'Expired'; return }
    $row.status = 'Approved'; $row.approver = 'synthetic-approver'
    if ($s['becomesCompliantBeforeRun']) { $devices[$row.device]['complianceState'] = 'compliant' }
    # B3: re-check before the write
    $r = Invoke-MockGraph 'GetManagedDevice' $row.device
    if ($r.status -ne 200 -or $r.body['complianceState'] -ne 'noncompliant') { $row.status = 'Skipped'; return }
    # B4: the only write
    $force = if ($s.ContainsKey('postStatus')) { [int]$s['postStatus'] } else { 0 }
    $w = Invoke-MockGraph -OperationId 'InitiateRemediation' -DeviceId $row.device -ForceStatus $force
    $row.status = if ($w.status -eq 204) { 'Remediated' } else { 'Failed' }
}

$results = foreach ($s in $scn['scenarios']) {
    $before = $calls.Count
    $out = Invoke-RequestRemediation $s
    if ($out -is [string]) { $actual = $out }
    else { Invoke-ApproveAndRun $out $s; $actual = $out.status }
    $writes = @($calls | Select-Object -Skip $before | Where-Object { $_.op -eq 'InitiateRemediation' }).Count
    [pscustomobject][ordered]@{ Id = $s['id']; Expected = $s['expect']; Actual = $actual; Writes = $writes; Pass = ($actual -eq $s['expect']) }
}
$failed = @($results | Where-Object { -not $_.Pass })

# ---- Output with idempotency ----
$inputHash = (Get-Sha256 $DesignPath) + (Get-Sha256 $swaggerPath) + (Get-Sha256 $ScenarioPath)
$resultsPath = Join-Path $OutputDirectory 'test-results.json'
$ledgerPath = Join-Path $OutputDirectory 'ledger.json'
if (Test-Path -LiteralPath $manifestPath) {
    $old = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
    $intact = @($old.files | Where-Object { $p = Join-Path $OutputDirectory $_.name; (Test-Path -LiteralPath $p) -and (Get-Sha256 $p) -eq $_.sha256 }).Count -eq @($old.files).Count
    if ($old.inputSha256 -eq $inputHash -and $intact) { Write-Output 'No changes: output already matches these inputs.'; return }
    if (-not $Force) { throw "Output in $OutputDirectory came from different input or was edited. Re-run with -Force." }
}
if ($PSCmdlet.ShouldProcess($OutputDirectory, "Write results for $(@($results).Count) synthetic scenarios")) {
    if (-not (Test-Path -LiteralPath $OutputDirectory)) { New-Item -ItemType Directory -Path $OutputDirectory | Out-Null }
    $results | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath $resultsPath -Encoding utf8
    $ledger | Select-Object requestId, device, script, status, approver, @{ n = 'created'; e = { $_.created.ToString('o') } } |
        ConvertTo-Json -Depth 3 | Set-Content -LiteralPath $ledgerPath -Encoding utf8
    [ordered]@{
        inputSha256 = $inputHash
        files = @(foreach ($p in $resultsPath, $ledgerPath) { [ordered]@{ name = Split-Path $p -Leaf; sha256 = Get-Sha256 $p } })
    } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifestPath -Encoding utf8
    Write-Audit 'write' "scenarios=$(@($results).Count) failed=$($failed.Count) graphWrites=$(@($calls | Where-Object op -eq 'InitiateRemediation').Count)"
}
$results | Format-Table -AutoSize | Out-String -Width 120 | Write-Output
if ($failed.Count -gt 0) { throw "$($failed.Count) scenario(s) failed: $(($failed.Id) -join ', ')" }

Results from the normal run:

ScenarioWhat it checksResultWrite calls
S01Approved request on a noncompliant Windows deviceRemediated1
S02Approver rejectsRejected0
S03Script package not on the allowlistRefused at A20
S04Device already compliantRefused at A30
S05iOS deviceRefused at A30
S06Second request for the S01 device inside the TTLRefused at A40
S07Approval after 25 hoursExpired0
S08Device turns compliant before the runSkipped0
S09Mock returns 429 on the writeFailed1
S10Malformed device IDRefused at A10
S11Device ID the mock doesn’t know (404)Refused at A30

The mock saw two write calls across 11 scenarios, both after an approval and a re-check.

5. Public-objective concepts to retain

These objective names come from the November 16, 2026 skills list:[1]

  • Design autonomous agents and Design task agents. A recurrence trigger makes the agent autonomous. Microsoft limits event triggers to the maker’s credentials and warns that users of the agent can reach data through that authorization.[5]
  • Design agents and agent flows with Copilot Studio. Agent flows are deterministic and follow a rule-based path.[3] They must respond within 100 seconds.[4] Long waits belong in a separate flow.
  • Design agent extensibility in Copilot Studio. Custom connectors take OpenAPI 2.0 under 1 MB.[8] REST API tools accept v2, and v3 is translated automatically.[7] x-ms-visibility hides parameters, and internal required parameters need defaults.[9]
  • Design security for agents and Design governance for agents. Delegated permissions can’t exceed the user’s own access.[24] Tool authentication is end user or maker-provided.[6] Admins control event triggers through data policies.[5]
  • Analyze solution and AI vulnerabilities and mitigations, including prompt manipulation. Built-in UPIA and XPIA protection exists,[25] and the allowlist, the read-only agent account and human approval don’t depend on it.
  • Recommend the process and metrics to test agents. Test runs don’t consume capacity.[3] Write calls per scenario and refusals per check are simple metrics a design test can report.

Approvals in agent flows also come in a multistage preview with AI stages. AI stages need Copilot Credits, and you can’t assign the same approver to more than one stage.[20] I kept one human stage for a device action.

6. Next step in the study journey

Next is Model Context Protocol and Computer Use: connecting agents to MCP servers and computer-use tools for AB-100’s “Design extensibility of AI solutions” objectives.[1] The ALM objective, “Design the ALM process for Copilot Studio agents, connectors, and actions,” fits this design’s connector and flows, and a later entry covers it.[1] For the governance basics behind the approval gate, see the AB-900 agent lifecycle entry. The AB-100 certification guide covers whether the Expert credential fits your career.

Sources

[1] https://learn.microsoft.com/en-us/credentials/certifications/resources/study-guides/ab-100 (Study guide for Exam AB-100) [2] https://learn.microsoft.com/en-us/credentials/certifications/exams/ab-100/ (Exam AB-100: Agentic AI Business Solutions Architect) [3] https://learn.microsoft.com/en-us/microsoft-copilot-studio/flows-overview (Agent flows overview) [4] https://learn.microsoft.com/en-us/microsoft-copilot-studio/flow-agent (Add an agent flow as a tool to an agent) [5] https://learn.microsoft.com/en-us/microsoft-copilot-studio/authoring-triggers-about (Event triggers overview) [6] https://learn.microsoft.com/en-us/microsoft-copilot-studio/add-tools-custom-agent (Add tools to custom agents) [7] https://learn.microsoft.com/en-us/microsoft-copilot-studio/agent-extend-action-rest-api (Extend your agent with tools from a REST API, preview) [8] https://learn.microsoft.com/en-us/connectors/custom-connectors/define-openapi-definition (Create a custom connector from an OpenAPI definition) [9] https://learn.microsoft.com/en-us/connectors/custom-connectors/openapi-extensions (Create a custom connector with an OpenAPI extension) [10] https://learn.microsoft.com/en-us/connectors/custom-connectors/azure-active-directory-authentication (Authenticate your API and connector with Microsoft Entra ID) [11] https://learn.microsoft.com/en-us/microsoft-copilot-studio/advanced-connectors (Use connectors in Copilot Studio agents) [12] https://learn.microsoft.com/en-us/graph/api/intune-devices-manageddevice-initiateondemandproactiveremediation?view=graph-rest-beta (initiateOnDemandProactiveRemediation action, Graph beta) [13] https://learn.microsoft.com/en-us/graph/api/intune-devices-manageddevice-get?view=graph-rest-1.0 (Get managedDevice) [14] https://learn.microsoft.com/en-us/graph/api/resources/intune-devices-manageddevice?view=graph-rest-1.0 (managedDevice resource type) [15] https://learn.microsoft.com/en-us/graph/api/intune-devices-devicehealthscript-list?view=graph-rest-beta (List deviceHealthScripts, Graph beta) [16] https://learn.microsoft.com/en-us/graph/api/intune-devices-devicehealthscriptdevicestate-list?view=graph-rest-beta (List deviceHealthScriptDeviceStates, Graph beta) [17] https://learn.microsoft.com/en-us/intune/device-management/tools/deploy-remediations (Use Remediations to detect and fix support issues) [18] https://learn.microsoft.com/en-us/intune/device-management/actions/run-remediation (Device action: Run remediation) [19] https://learn.microsoft.com/en-us/connectors/approvals/ (Standard approvals connector reference) [20] https://learn.microsoft.com/en-us/microsoft-copilot-studio/flows-advanced-approvals (Multistage and AI approvals in agent flows, preview) [21] https://learn.microsoft.com/en-us/connectors/commondataserviceforapps/ (Microsoft Dataverse connector reference) [22] https://learn.microsoft.com/en-us/connectors/teams/ (Microsoft Teams connector reference) [23] https://learn.microsoft.com/en-us/power-automate/limits-and-config (Limits of automated, scheduled and instant flows) [24] https://learn.microsoft.com/en-us/graph/permissions-overview (Overview of Microsoft Graph permissions) [25] https://learn.microsoft.com/en-us/microsoft-copilot-studio/external-security-provider (Enable external threat detection and protection for Copilot Studio custom agents, preview) [26] https://learn.microsoft.com/en-us/connectors/custom-connectors/ (Custom connectors overview)

Free PDF

Top 10 Intune and Autopilot Enrollment Errors: Causes and Fixes

A free cheat sheet covering ten enrollment errors, each with the symptom, the most likely cause, the fix, and a link to the full guide.

  • Ten real error codes and failures, including 0x80180014, 0x8018002b, 0x801c03f3, 0x800705b4, the ODJConnector logon failure, and the Win32 detection error 0x87d1041c.
  • Every cause and fix checked against Microsoft's own documentation.
  • A link from each entry to the full zakitpro.com article.

You get the free PDF. By subscribing you also agree to receive occasional emails from zakitpro about Intune and Autopilot troubleshooting. Emails are sent through MailerLite. You can unsubscribe anytime with the link in every email. See the privacy page for how your email is handled.

#ab-100 #copilot studio #power automate #intune remediations #microsoft graph #exam prep