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:

RequirementNotes
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_licenses on 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_licenses record carries large AssignedUsers and ServicePlans arrays. 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 single cipp_list_tenants call at the top of the routine is acceptable instead.
  • Read seat counts from the list payload — ignore the heavy arrays. Each cipp_list_licenses record already exposes CountUsed (assigned), TotalLicenses (purchased), and CountAvailable (unassigned). The AssignedUsers and ServicePlans arrays 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_tools must be populated per connector. A routine with a connector attached but an empty permitted_tools list 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.