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.
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
$selectthe properties you need. Default property sets are large and change. - Follow
@odata.nextLinkand@odata.deltaLinkURLs exactly as returned; do not rebuild them from parts. - Use
v1.0in production.betaendpoints change without notice.
Choose the permission type
| Scenario | Permission type | Token |
|---|---|---|
| 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 SaaS | Multi-tenant registration, tenant admin consent via /adminconsent | Acquire 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:
| Resource | How to restrict app-only access |
|---|---|
| Exchange mail/calendar/contacts | Exchange 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 / OneDrive | Request Sites.Selected, then an admin grants the app read or write on specific sites via POST /sites/{site-id}/permissions |
| Teams chats and channels | Resource-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
| Task | Request | Notes |
|---|---|---|
| List mail | GET /users/{id}/mailFolders/inbox/messages?$select=subject,from,receivedDateTime&$top=50 | Use /me only with delegated tokens |
| Send mail | POST /users/{id}/sendMail with { message, saveToSentItems } | Returns 202, no message ID |
| Calendar window | GET /users/{id}/calendarView?startDateTime=...&endDateTime=... | Expands recurrences; add Prefer: outlook.timezone="Europe/Berlin" |
| Resolve SharePoint site | GET /sites/{hostname}:/sites/{site-path} | Then /sites/{site-id}/drives |
| Download file | GET /drives/{drive-id}/items/{item-id}/content | Follows a 302 to a pre-authenticated URL; do not forward the bearer token there |
| Upload > 4 MB | POST .../createUploadSession, then PUT byte ranges | Chunk sizes in multiples of 320 KiB |
| Directory search | GET /users?$filter=...&$count=true with ConsistencyLevel: eventual | Advanced 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
@removedannotation; 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/resyncRequirederror 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:
- Validation: when the request has a
validationTokenquery 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. - For each item in
value[], compareclientStateto the stored value forsubscriptionId(constant-time). Drop mismatches and alert. - Enqueue and return 202 within 3 seconds. Slow endpoints are marked “slow” (delayed delivery) and then “drop” (notifications lost permanently).
- The notification is a trigger; run a delta query for the affected resource rather than trusting it as the full change.
- With
includeResourceData: true, the payload is encrypted with your certificate and includesvalidationTokens(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 event | Action |
|---|---|
reauthorizationRequired | POST /subscriptions/{id}/reauthorize or renew via PATCH |
subscriptionRemoved | Create a new subscription, then delta-sync to cover the gap |
missed | Delta-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-Afterseconds, 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 /$batchwith up to 20 requests; each is throttled individually, so check every innerstatusand retry only the failed ones. UsedependsOnfor order.
Gotchas
/mewith 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.Selectedfence 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
audishttps://graph.microsoft.com,tidis the right tenant, androles/scplists 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 wrongclientStateand 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-Afterand 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.