Microsoft Graph skill for Claude Code

Outlook, OneDrive and Teams calls with fenced permissions. Claude registers Entra ID apps with the fewest permissions of the right type, fences application access to specific mailboxes and SharePoint sites, and authenticates with certificates or federated identities. Sync runs on delta links, change notification endpoints validate tokens and clientState and handle lifecycle events, and throttled calls wait out Retry-After instead of hammering Graph.

Name
microsoft-graph
Size
221 lines · ≈2,700 tokens
Files
1
License
MIT
Updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds Microsoft 365 integrations on Microsoft Graph - Entra ID app registration, delegated vs application permissions, MSAL and certificate or federated credentials, scoping app access to specific mailboxes and SharePoint sites, Outlook mail and calendar, OneDrive and SharePoint files, Teams messages, paging, delta queries, JSON batching, 429 throttling with Retry-After, and change notification subscriptions with validation, clientState, and lifecycle events. Use when calling graph.microsoft.com, syncing Outlook or OneDrive data, posting to Teams, or debugging 401, 403 Authorization_RequestDenied, or throttling.

Try asking

  • Our service needs to read one shared mailbox via Microsoft Graph - set it up without tenant-wide Mail.Read
  • Sync OneDrive files into our app and get notified when they change
  • Graph keeps returning 429 during our nightly mail export - fix the throttling handling

Use it when

  • Calling graph.microsoft.com for Outlook, calendar, OneDrive, SharePoint or Teams data
  • Registering an Entra ID app and choosing delegated vs application permissions
  • Setting up Graph change notifications or delta sync
  • Debugging Graph 401, 403 Authorization_RequestDenied, or 429 throttling

Skip it when

  • You only need Sign in with Microsoft for identity - use oauth-oidc instead
  • The data lives in Google Drive, Gmail or Google Calendar - use google-workspace-apis instead

How to check the result

Decoded tokens list only the intended roles or scopes, calls outside the permission fence return 403, a change produces a notification with the right clientState, and a repeated delta run returns no items.

The common mistake it prevents

Granting tenant-wide application permissions like Mail.Read or Sites.ReadWrite.All without RBAC for Applications or Sites.Selected, giving a background job access to every mailbox and site in the company.

~/.claude/skills/microsoft-graph/SKILL.md

--- name: microsoft-graph description: Builds Microsoft 365 integrations on Microsoft Graph - Entra ID app registration, delegated vs application permissions, MSAL and… license: MIT ---

Done well means: the app registration has the fewest permissions of the right type, application permissions are fenced to the mailboxes and sites the app actually needs, credentials are certificates or federated identities (not long-lived secrets in env files), sync uses delta tokens, and the integration slows down when Graph says 429.

Ground rules

  • No secrets in code. Prefer, in order: managed identity (Azure-hosted), workload identity federation (GitHub Actions, Kubernetes, other clouds), certificate credential stored in Key Vault, and only then a client secret from a secret manager with a short expiry and a rotation reminder.
  • Use MSAL (or Azure Identity) for tokens; never hand-roll the token endpoint. Cache tokens in memory and let the library refresh them.
  • Always $select the properties you need. Default property sets are large and change.
  • Follow @odata.nextLink and @odata.deltaLink URLs exactly as returned; do not rebuild them from parts.
  • Use v1.0 in production. beta endpoints change without notice.

Choose the permission type

ScenarioPermission typeToken
Signed-in user acts on their own data (web or desktop app)Delegated (Mail.Read, Calendars.ReadWrite)Auth code + PKCE via MSAL; token has scp claim
Background service across a tenant (sync, archiving, provisioning)Application (Mail.Read, Sites.Selected)Client credentials, scope https://graph.microsoft.com/.default; token has roles claim
Multi-tenant SaaSMulti-tenant registration, tenant admin consent via /adminconsentAcquire tokens per customer tenant ID, never common for app-only

Application permissions apply to every mailbox or site in the tenant unless you fence them:

ResourceHow to restrict app-only access
Exchange mail/calendar/contactsExchange Online RBAC for Applications (scoped management roles assigned to the service principal for a mail-enabled group or custom scope); older tenants may use Application Access Policies
SharePoint / OneDriveRequest Sites.Selected, then an admin grants the app read or write on specific sites via POST /sites/{site-id}/permissions
Teams chats and channelsResource-specific consent (RSC) permissions granted per team or chat at app install

Least-privilege defaults: Mail.Send (not Mail.ReadWrite) for sending, Files.ReadWrite delegated or Sites.Selected (not Sites.ReadWrite.All), User.Read.All only for directory sync.

Workflow

- [ ] 1. Register the app (single- or multi-tenant), add redirect URIs for delegated flows
- [ ] 2. Add minimal permissions; get admin consent; fence application permissions
- [ ] 3. Configure credentials (managed identity / federated / certificate)
- [ ] 4. Build the client with MSAL or Azure Identity
- [ ] 5. Implement initial load + delta sync, with paging
- [ ] 6. Add change notifications with validation, clientState, lifecycle handling
- [ ] 7. Add throttling handling and batching
- [ ] 8. Verify token claims, permissions fence, and the notification lifecycle

4. Client

Node, app-only with a certificate (MSAL Node):

import { ConfidentialClientApplication } from '@azure/msal-node';

const cca = new ConfidentialClientApplication({
  auth: {
    clientId: process.env.GRAPH_CLIENT_ID!,
    authority: `https://login.microsoftonline.com/${process.env.GRAPH_TENANT_ID}`,
    clientCertificate: {
      thumbprintSha256: process.env.GRAPH_CERT_THUMBPRINT_SHA256!,
      privateKey: process.env.GRAPH_CERT_PRIVATE_KEY!,   // PEM from Key Vault
    },
  },
});

async function graph(path: string, init: RequestInit = {}) {
  const { accessToken } = (await cca.acquireTokenByClientCredential({
    scopes: ['https://graph.microsoft.com/.default'],
  }))!;
  return fetch(`https://graph.microsoft.com/v1.0${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', ...init.headers },
  });
}

Python, app-only:

import msal, os
app = msal.ConfidentialClientApplication(
    client_id=os.environ["GRAPH_CLIENT_ID"],
    authority=f"https://login.microsoftonline.com/{os.environ['GRAPH_TENANT_ID']}",
    client_credential={"private_key": os.environ["GRAPH_CERT_PEM"],
                       "thumbprint": os.environ["GRAPH_CERT_THUMBPRINT"]},
)
token = app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
if "access_token" not in token:
    raise RuntimeError(token.get("error_description"))

The official Graph SDKs (@microsoft/microsoft-graph-client, msgraph-sdk for Python, Microsoft.Graph for .NET) add typed models, retry, and page iterators; use them when the codebase already does, raw HTTP plus MSAL is fine otherwise.

5. Common calls

TaskRequestNotes
List mailGET /users/{id}/mailFolders/inbox/messages?$select=subject,from,receivedDateTime&$top=50Use /me only with delegated tokens
Send mailPOST /users/{id}/sendMail with { message, saveToSentItems }Returns 202, no message ID
Calendar windowGET /users/{id}/calendarView?startDateTime=...&endDateTime=...Expands recurrences; add Prefer: outlook.timezone="Europe/Berlin"
Resolve SharePoint siteGET /sites/{hostname}:/sites/{site-path}Then /sites/{site-id}/drives
Download fileGET /drives/{drive-id}/items/{item-id}/contentFollows a 302 to a pre-authenticated URL; do not forward the bearer token there
Upload > 4 MBPOST .../createUploadSession, then PUT byte rangesChunk sizes in multiples of 320 KiB
Directory searchGET /users?$filter=...&$count=true with ConsistencyLevel: eventualAdvanced queries require that header

Teams: sending channel or chat messages through Graph needs delegated permissions; application permissions for posting messages are limited to migration scenarios. For a service that posts notifications, use a bot (Bot Framework / Teams SDK) or a Workflows webhook instead.

5b. Delta sync

GET /users/{id}/mailFolders/inbox/messages/delta?$select=subject,receivedDateTime
  -> follow @odata.nextLink pages until a page returns @odata.deltaLink
  -> store deltaLink; next run GET deltaLink
  • Deleted items arrive with an @removed annotation; apply deletes.
  • Supported on users, groups, mail folders/messages, events in a calendar view, drive items (/drives/{id}/root/delta), and more; check the resource’s docs.
  • HTTP 410 or a syncStateNotFound/resyncRequired error means start over with a fresh delta call and reconcile.
  • Drive delta returns every item under the root; filter client-side by path.

6. Change notifications

Subscribe:

POST /v1.0/subscriptions
{
  "changeType": "created,updated,deleted",
  "notificationUrl": "https://api.example.com/hooks/graph",
  "lifecycleNotificationUrl": "https://api.example.com/hooks/graph-lifecycle",
  "resource": "/users/{id}/mailFolders('inbox')/messages",
  "expirationDateTime": "<now + under the resource maximum>",
  "clientState": "<random per subscription, stored server-side>"
}

Endpoint rules:

  1. Validation: when the request has a validationToken query parameter, respond within 10 seconds with 200, Content-Type: text/plain, and the URL-decoded token as the body. Do this on both the notification and lifecycle URLs.
  2. For each item in value[], compare clientState to the stored value for subscriptionId (constant-time). Drop mismatches and alert.
  3. Enqueue and return 202 within 3 seconds. Slow endpoints are marked “slow” (delayed delivery) and then “drop” (notifications lost permanently).
  4. The notification is a trigger; run a delta query for the affected resource rather than trusting it as the full change.
  5. With includeResourceData: true, the payload is encrypted with your certificate and includes validationTokens (JWTs) that you must validate (issuer, audience = your app ID) before decrypting.

Lifetimes vary by resource (for example, Outlook messages/events under 7 days, drive items and SharePoint lists under 30 days, Teams resources about 3 days, presence 1 hour). Store expirationDateTime, renew with PATCH /subscriptions/{id} well before it (e.g. at 75% of lifetime), and handle lifecycle events:

Lifecycle eventAction
reauthorizationRequiredPOST /subscriptions/{id}/reauthorize or renew via PATCH
subscriptionRemovedCreate a new subscription, then delta-sync to cover the gap
missedDelta-sync the resource

Creating a subscription identical to an existing one (same resource and changeType) returns 409; list and reuse instead.

7. Throttling and batching

  • On 429 (or 503), wait the Retry-After seconds, then retry; if absent, exponential backoff with jitter. Never retry immediately in a tight loop; it extends the throttle.
  • Limits are per service and per app/tenant/resource. Outlook is strict per mailbox (roughly thousands of requests per 10 minutes and a handful of concurrent requests per mailbox); parallelize across mailboxes, not within one.
  • JSON batching: POST /$batch with up to 20 requests; each is throttled individually, so check every inner status and retry only the failed ones. Use dependsOn for order.

Gotchas

  • /me with an app-only token fails: there is no signed-in user. Use /users/{id|upn}.
  • Authorization_RequestDenied / 403 usually means the permission was added but admin consent was not granted, or the RBAC/Sites.Selected fence excludes that resource.
  • Changing permissions does not alter already-issued tokens; acquire a new token (or wait out the cache) before retesting.
  • Client secrets expire (max 24 months); an expired secret takes production down silently at midnight. Calendar the expiry or move to federated credentials.
  • Message and event IDs change when items move between folders unless you request immutable IDs with Prefer: IdType="ImmutableId".
  • National clouds (GCC High, China) use different Graph and login hosts; make both configurable.

Verification

  • Decode an access token (locally, e.g. with a JWT library) and check aud is https://graph.microsoft.com, tid is the right tenant, and roles/scp lists only the intended permissions.
  • Prove the fence: an app-only call to a mailbox or site outside the allowed scope must return 403.
  • Create a subscription, confirm validation succeeds, change one item, and see a notification with the correct clientState; send a forged POST with a wrong clientState and confirm it is dropped.
  • Run delta twice with no changes and confirm the second run returns zero items.
  • Force throttling in a test tenant with a burst and confirm requests back off per Retry-After and complete.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc