M365 License Auditor
A worked example of an advanced workflow: a Claude-managed scheduled agent that, once a month, reads the Microsoft 365 license data for a fixed set of tenants through CIPP, flags every SKU with more seats purchased than assigned, estimates the wasted spend, and publishes a tenant-by-tenant report to Slack as a canvas with a one-line summary. There are no servers and no code to deploy: the agent is a saved prompt plus a schedule plus two MCP connectors, running in Claude's cloud.
What it builds
The finished workflow runs unattended on a monthly cron. Each run it reads the license SKUs for each configured tenant, reasons about which ones have unassigned seats, estimates the monthly waste at a stated blended per-seat cost, groups the findings by tenant with a portfolio total, and notifies your team, turning scattered license sprawl into a single waste report with a Slack trail.
Prerequisites
Before building, your Claude account needs all of the following:
| Requirement | Notes |
|---|---|
| WYRE MCP Gateway connector | Connected in claude.ai (https://mcp.wyre.ai/v1/mcp). Provides the cipp_* tools. See the Gateway overview. |
| CIPP enabled in the gateway | The gateway's CIPP integration must be configured and able to read tenants and license data across your managed Microsoft 365 estate. |
| Slack connector | The first-party Slack connector connected in claude.ai (https://mcp.slack.com/mcp). Provides slack_create_canvas and slack_send_message. |
| Scheduled routines access | Created via the /schedule capability; managed at claude.ai/code/routines. |
| A destination Slack channel | e.g. #sw-dev, plus its channel ID. The Slack connector must have access to it. |
Known gotchas
These are the things that cost real time the first time through. Account for them up front and the build genuinely takes minutes.
- CIPP is the license source — the Microsoft 365 connector cannot
enumerate licenses. The Microsoft 365 connector available to claude.ai
is search/read only — it surfaces mail, files, and calendar, but it cannot list
tenant license SKUs or seat counts. License auditing has to go through CIPP's
cipp_list_licenseson the gateway, which is why the routine attaches the WYRE MCP Gateway connector rather than the Microsoft 365 one. - Scope the routine to a fixed set of tenants. WYRE's CIPP manages
33 Microsoft 365 tenants, and each
cipp_list_licensesrecord carries largeAssignedUsersandServicePlansarrays. Sweeping every tenant in one routine run is heavy and risks the run drowning in payload before it can finish — the same failure mode the Patch Drift Reporter hit. So this routine is scoped to a fixed set of about six tenants whose combined license data a run can comfortably hold, with their tenant filters baked into the prompt. With a modest tenant count (under ~15) a singlecipp_list_tenantscall at the top of the routine is acceptable instead. - Read seat counts from the list payload — ignore the heavy arrays.
Each
cipp_list_licensesrecord already exposesCountUsed(assigned),TotalLicenses(purchased), andCountAvailable(unassigned). TheAssignedUsersandServicePlansarrays on each record are not needed for the audit — the routine is told to ignore them. - The waste figure is an estimate with a stated assumption. The routine does not look up exact per-SKU pricing — it applies one blended per-seat cost (USD 20/month) and states that assumption in the report. Treat the dollar figure as a directional signal, not an invoice.
-
permitted_toolsmust be populated per connector. A routine with a connector attached but an emptypermitted_toolslist runs with no tools and silently does nothing — no error, no output. List the exact tool names the routine needs. - Faster-than-hourly cadences are rejected. The routine scheduler will not accept a cron more frequent than hourly. A monthly cron is well within bounds — and the right cadence for license churn anyway.
- A routine reaches only its attached connectors. The routine sandbox blocks arbitrary network egress, so notifications must go through the Slack connector's tools, not an outbound webhook. Attach every connector the workflow touches.
The one-shot build prompt
With the connectors above in place, paste this to Claude. It confirms the gateway, creates the routine, and verifies it end to end.
Build me a scheduled M365 License Auditor agent. Do all of this end to end:
1. Confirm the WYRE MCP Gateway works and CIPP is reachable: call
cipp_list_tenants and check it returns your managed Microsoft 365 tenants.
2. Pick the tenants this audit should cover. cipp_list_licenses returns one
record per license SKU, and each record carries large AssignedUsers and
ServicePlans arrays - sweeping every tenant in one routine run is heavy.
If you manage more than ~15 tenants, choose a fixed set of about six and
record each tenant's tenantFilter (domain or ID). These get baked into the
routine prompt. With a modest tenant count you may keep it portfolio-wide.
3. Confirm a Slack connector is connected. Note the destination channel name
and ID (e.g. #sw-dev). If Slack does not show in the /schedule connector
list, read its connector_uuid and url from an existing routine that already
uses Slack (RemoteTrigger list -> get -> mcp_connections).
4. Create a Claude-managed scheduled routine named "M365 License Auditor":
- Schedule: monthly, cron "0 13 1 * *" (1st of the month, 09:00
America/New_York = 13:00 UTC). Faster-than-hourly cadences are rejected.
- Attach TWO connectors, each with permitted_tools populated:
* WYRE MCP Gateway: cipp_list_licenses
* Slack: slack_create_canvas, slack_send_message
An empty permitted_tools list = the routine runs with no tools.
- Routine prompt: every run, read license data for each baked-in tenant,
flag SKUs where purchased seats exceed assigned seats, estimate the
monthly waste with a stated assumed per-seat cost, build a report grouped
by tenant with a portfolio total, publish it as a Slack canvas, and post
a one-line summary linking the canvas. Use the exact routine prompt below,
with your own tenant filters.
5. Trigger a manual run and verify: a canvas titled "M365 License Audit -
<month year>" was created, a one-line summary landed in the destination
channel, and the report content matches the live tenants (spot-check one). The resulting routine prompt
This is the lean prompt the build process installs into the scheduled routine itself. Substitute your own tenant filters and destination channel.
You are the M365 License Auditor. You run monthly. Keep it lean.
Use ONLY: cipp_list_licenses, slack_create_canvas, slack_send_message.
Do NOT call cipp_list_tenants - the tenants are listed below.
Tenants to audit (tenantFilter - name):
- <tenant-id-1> - <tenant name 1>
- <tenant-id-2> - <tenant name 2>
- ... about six tenants total ...
1. For each tenant above, call cipp_list_licenses with that tenantFilter. Each
record is one license SKU and carries: License/skuPartNumber (SKU name),
CountUsed (assigned seats), TotalLicenses (purchased seats), and
CountAvailable/availableUnits (unassigned seats). Ignore the large
AssignedUsers and ServicePlans arrays on each record - they are not needed.
2. For each tenant, flag every SKU where TotalLicenses exceeds CountUsed. The
unassigned count for that SKU is TotalLicenses minus CountUsed (= wasted
seats). Skip SKUs that are fully assigned.
3. Estimate monthly waste. Assume a typical per-seat cost of USD 20/month as a
blended rate across SKUs - STATE this assumption explicitly in the report;
do not look up or invent exact per-SKU pricing. Per-tenant waste = total
unassigned seats x USD 20. Portfolio waste = sum across tenants.
4. Build a report grouped by tenant: tenant name, and for each flagged SKU the
SKU name, assigned seats, purchased seats, and unassigned count; then the
tenant's total unassigned seats and estimated monthly waste. End with a
portfolio total: total unassigned seats and total estimated monthly waste.
Include the line 'Estimate assumes a blended USD 20/seat/month; actual
per-SKU pricing varies.'
5. slack_create_canvas titled "M365 License Audit - <current month and year>"
with the full grouped report. Then slack_send_message to #sw-dev
(channel ID C0931CKJ75X): a one-line summary ('M365 license audit: ~$N/mo
estimated waste across 6 tenants - see canvas') linking the canvas.
6. If a tenant cannot be read, list it in a "could not check" section instead
of skipping it silently. How it works
A monthly cadence, by design
License churn is slow — seats are added and removed over weeks as people join and leave, not minute to minute. A monthly run on the 1st catches the previous month's drift in time for the billing cycle, and a once-a-month sweep is kind to CIPP: one pass per month, not one per hour.
Waste is unassigned seats times a stated per-seat cost
For each SKU the routine compares TotalLicenses (purchased) against
CountUsed (assigned); the difference is unassigned seats, licenses paid
for but not in use. It multiplies the unassigned total by one blended per-seat cost
(USD 20/month) to estimate monthly waste, and the report states that assumption
plainly: "Estimate assumes a blended USD 20/seat/month; actual per-SKU pricing
varies." The routine deliberately does not invent exact per-SKU prices; the goal is
a directional waste signal that is consistent month to month, not a precise invoice.
It covers a fixed set of tenants, not the whole portfolio
Each cipp_list_licenses record carries large AssignedUsers
and ServicePlans arrays. Across 33 managed tenants that payload swamps a
single routine run before it can finish. So the routine is scoped to a fixed set of
about six tenants whose combined license data a run can comfortably hold, with their
tenant filters baked into the prompt. A smaller portfolio could instead let the
routine call cipp_list_tenants once and loop everything.
Nothing is dropped silently
If a tenant's license data cannot be read — a CIPP refresh error, a lapsed delegated admin relationship — the routine lists it in a "could not check" section rather than omitting it. A tenant missing from the report always means a real gap.
Delivery is a Slack canvas plus a summary
The full tenant-by-tenant report can be long, so the routine publishes it as a Slack canvas, a durable document teammates can scroll and reference, and posts only a one-line summary to the channel with the portfolio total estimated waste, linking the canvas. The channel stays readable; the detail lives in the canvas. See delivery adapters for other ways to surface a routine's output.
Extending it
The natural next step is to cross-reference assigned seats against account state:
a license assigned to a disabled user is waste that the purchased-vs-assigned
comparison alone misses. Adding cipp_list_users to the routine lets it
flag licenses held by disabled accounts as recoverable on top of the unassigned-seat
count. The waste estimate can also be tightened: swap the single blended rate for a
small per-SKU price map baked into the prompt. The report-building and Slack-delivery
body of the routine is identical.
Questions or a workflow you'd like documented?
Open an issue
in the msp-claude-plugins repository.