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 approach | AI-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
- Recurrence trigger. The agent wakes on a schedule. Event triggers need generative orchestration turned on.[5]
- Read tools. The agent calls three read-only connector operations: list noncompliant devices, get one device and list remediation script packages.
- 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.
- 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
| Requirement | What Microsoft documents |
|---|---|
| Remediations licensing | Users 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] |
| Device | Microsoft Entra joined or hybrid joined, and either MDM enrolled running Windows Enterprise, Professional or Education, or co-managed.[17] |
| On-demand run | Run 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 run | Help 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 run | DeviceManagementManagedDevices.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 reads | DeviceManagementManagedDevices.Read.All for devices,[13] DeviceManagementScripts.Read.All for script packages and run states.[15][16] |
| Copilot Studio | REST 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 billing | Each trigger payload counts as a message. A recurrence every 10 minutes sends a message every 10 minutes.[5] |
| Environment settings | Solution-aware cloud flow sharing must be on for event triggers, and admins can block event triggers through data policies.[5] |
| Approvals | Power 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.
- 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] - 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.
- Hide what the agent shouldn’t touch. Parameters marked
internalare hidden from the user, and internal required parameters must have a default.[9] The connector fixes$filtertocomplianceState eq 'noncompliant'and fixes$selectthe same way. Failure point: an internal required parameter without a default.[9] - 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]
- 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]
- 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.
- 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]
- 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
$topat 10. Failure point: a short interval burns messages.[5] - 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:
| Scenario | What it checks | Result | Write calls |
|---|---|---|---|
| S01 | Approved request on a noncompliant Windows device | Remediated | 1 |
| S02 | Approver rejects | Rejected | 0 |
| S03 | Script package not on the allowlist | Refused at A2 | 0 |
| S04 | Device already compliant | Refused at A3 | 0 |
| S05 | iOS device | Refused at A3 | 0 |
| S06 | Second request for the S01 device inside the TTL | Refused at A4 | 0 |
| S07 | Approval after 25 hours | Expired | 0 |
| S08 | Device turns compliant before the run | Skipped | 0 |
| S09 | Mock returns 429 on the write | Failed | 1 |
| S10 | Malformed device ID | Refused at A1 | 0 |
| S11 | Device ID the mock doesn’t know (404) | Refused at A3 | 0 |
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-visibilityhides 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)