skills/msgraph-integration-patterns/SKILL.md
Hard-won patterns for probing, building, troubleshooting, and iterating against Microsoft Graph API endpoints -- especially from a browser SPA using delegated MSAL.js auth calling Graph directly with no backend (lessons generalize to any Graph integration). Covers the throwaway-probe-file methodology for de-risking before building, OData/query quirks, permission and admin-consent sequencing, recordings/transcripts access patterns (SharePoint REST, not Graph), CSP requirements for a pure-browser SPA, retry/pagination/backoff patterns, and the MSAL/EasyAuth auth-redirect-loop debugging saga. Use when integrating with Microsoft Graph, Teams APIs, MSAL.js, or EasyAuth; when hitting an unexpected Graph error (400/403/429), a silent missing-scope failure, an auth redirect loop, or a CSP violation that only appears in production; or when deciding how to validate a new Graph capability before committing it to a codebase.
npx skillsauth add microsoft/amplifier-bundle-skills msgraph-integration-patternsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Hard-won lessons from building a Teams-data SPA (chats, channel threads, recordings/transcripts) against Microsoft Graph with delegated MSAL.js auth. Real evidence throughout -- not generic API advice.
Before writing any TypeScript/app code against a new Graph capability, write a throwaway, standalone HTML file with zero build tooling:
<script type="module">
import { PublicClientApplication } from "https://esm.sh/@azure/msal-browser";
// hardcode the real client id -- client ids are not secrets
const pca = new PublicClientApplication({ auth: { clientId: "...", authority: "..." } });
// real interactive login, then a raw fetch() to the EXACT endpoint/query
// render BOTH a summary AND the raw JSON of the first result --
// the raw JSON is "proof of shape," including undocumented-feeling fields
// like a replies[] array under $expand
</script>
This decouples two different risks: "does this scope/endpoint even return what I expect" (cheap, interactive, real credentials, zero build step) from "is my app's abstraction correct." A wrong assumption about Graph's actual behavior gets caught in seconds instead of after a full feature is built on it. Delete the probe once you have the answer -- never commit or ship it.
Treat admin-consent lead time as a hard roadmap gate: confirm consent is available for a scope before starting the feature that needs it, not as a mid-feature surprise. Run genuine investigation spikes (not ad hoc debugging) for open questions, and record the negative result permanently (a commit message, a design-doc line) so nobody re-tries a dead end later without knowing why it failed.
$top can be rejected on specific sub-endpoints even when it works on
the parent -- e.g. allowed on /me/chats and /{chat}/messages, but
/me/chats/{id}/members throws Query option 'Top' is not allowed. Don't
assume a query option that works on one endpoint works on its neighbors.$expand=replies is the only way to get thread replies inline on channel
messages: /teams/{teamId}/channels/{channelId}/messages?$top=50&$expand=replies.
There's no server-side "last activity" sort for threads -- compute
max(root.createdDateTime, all replies.createdDateTime) client-side.$filter on chat-message dates. Page newest-first, stop once you hit
your since boundary or run out of messages, filter client-side. This
pattern recurs everywhere Graph lacks a date filter (recordings windowing,
etc.) -- assume no server-side date filter until proven otherwise.@odata.nextLink is an absolute URL -- pass it straight to fetch(),
don't re-prefix it with your base URL.$count support is not universal across endpoints (e.g. transcript
endpoints don't support it) -- declare per-source capabilities and adapt
UI/warnings rather than assuming uniform support.19:; passing an Exchange/Outlook
item ID (AAMk...) to a chat endpoint returns 400 "Invalid MRI, should start with digits and colon". Validate ID shape client-side before firing
the request.openid, profile, email); a separate app for the actual
Graph content scopes. A leaked login credential then can't pivot to content
access.Sites.Read.All under "Office 365 SharePoint Online" is not the same grant
as Microsoft Graph's own Sites.Read.All, and backs a different bearer
token (https://{spHost}/.default)./AADSTS65001|invalid_grant|InteractionRequiredAuthError/i against the
error; on match for an optional feature, set a session-scoped flag that
suppresses further attempts for the rest of the page session (self-heals on
reload once the scope is granted) instead of spamming retries or the
console.AADSTS50105 fires when a role assignment flows through a nested
subgroup (must be a direct member for app-role assignment); a
mail-enabled security group silently fails to emit the roles claim even
though the portal shows the assignment as successful (recreate as a pure
security group); the groups claim is replaced by
_claim_names/_claim_sources once a user is in 200+ groups (resolving
needs an OBO call with a client secret -- generally blocked for pure SPAs,
so prefer app roles over group claims when overage is possible)./me/onlineMeetings transcript/recording endpoints are a confirmed dead
end for "recordings I can access" -- they only work for meetings the
signed-in user organized. getAllTranscripts()/getAllRecordings()
enforce userId == organizerId; the meetingOrganizerUserId=null "get all"
syntax doesn't work at v1.0 or beta (both 400); passing your own oid returns
412 "not supported in delegated context." /search/query for
filetype:mp4 returns 0 results under delegated auth./me/chats messages and read
eventDetail[\"@odata.type\"] === \"#microsoft.graph.callRecordingEventMessageDetail\"
directly out of the message payload. This surfaces recordings regardless
of who organized the meeting, including recordings in another user's
OneDrive. Cross-join participants from sibling callEndedEventMessageDetail
events by shared callId, falling back to plain chat membership when no
callEnded event exists (ad-hoc 1:1 calls never emit one)./me/onlineMeetings API specifically -- not a real data limit. This
reframing is the core insight: chat-membership access ≠ organizer access,
and Graph's official transcript API enforces the wrong boundary.GET /shares/{shareId}/driveItem (Graph, Files.Read.All) to resolve
driveId/itemId from a sharing URL. shareId = "u!" + base64url(url),
strip = padding.GET https://{spHost}/_api/v2.1/drives/{driveId}/items/{itemId}?select=name,media/transcripts&$expand=media/transcripts
-- Graph does not expose media/transcripts; this is a separate
SharePoint-resource bearer token (https://{spHost}/.default), not the
Graph token.temporaryDownloadUrl to /streamContent?is=1&applymediaedits=false
to get raw VTT./shares/{id}/driveItem
returns 400 "Invalid hostname for this tenancy" when the file lives in
another org's SharePoint -- give this its own error type so callers can
count/label it, not treat it as a bug.truncated: true and say so loudly in the UI -- never
silently incomplete.Built up incrementally -- every addition was driven by a production-only failure the local dev server never surfaced, since CSP is browser-enforced, not dev-server-enforced:
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';
connect-src 'self' https://login.microsoftonline.com https://graph.microsoft.com
https://*.sharepoint.com;
frame-src 'self' https://login.microsoftonline.com https://identity.{n}.azurestaticapps.net;
img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self';
frame-ancestors 'none'; form-action 'self'
style-src 'unsafe-inline' -- dynamic inline styles, invisible until prod.connect-src *.sharepoint.com -- OneDrive AppFolder sync + transcript
downloads share one wildcard (add further host patterns per environment,
see §3's last point).frame-src entries were added to support an ssoSilent hidden-iframe
approach that was later ripped out entirely (§6) -- left in place as
harmless once no longer load-bearing. Lesson: a CSP entry added for one
strategy can outlive that strategy -- don't assume every entry still has
a live purpose; don't be surprised when removing a CSP entry breaks nothing.429, 502, 503, 504. Everything else (400/401/403/404…)
throws immediately with the (truncated) response body in the error -- never
blind-retry a client error.Retry-After (seconds), clamp to a sane max (~60s) against a
misbehaving value.Retry-After (e.g.
~1s doubling, capped ~30s, random jitter to avoid thundering-herd retries).Promise.all over hundreds of items -- bounds throttling risk.This was the single most-iterated area. Presented as a sequence because the sequence is the lesson: four plausible-looking root causes in a row were wrong, and each "fix" that didn't work still taught something.
frame-src was too strict for MSAL's hidden
ssoSilent iframe returning to the app's own origin. Added 'self' to
frame-src. Loop persisted.frame-src to ride an existing session cookie. Added it. Loop
persisted.X-Frame-Options: deny) -- unfixable from the app side. Fix: remove
the iframe-based silent-SSO bridge entirely, fall through to a full
top-level redirect login (no iframe, so X-Frame-Options doesn't apply).
Accepted UX cost: one click instead of a broken zero-click bridge./auth-callback) above the catch-all auth rule; point
redirectUri at it; only navigate into the app after
handleRedirectPromise() resolves an account. Confirmed by checking the
console showed zero CSP violations during the failure -- proof it was
never a CSP problem, which is what fixes 1-2 had assumed.Net architecture: no iframe-based silent SSO. On load: init →
handleRedirectPromise() → if on the callback route with an account now
set, client-side redirect to / → else reuse a cached account if present
→ else show a sign-in button that does a full top-level redirect login.
Per-Graph-call fallback is separate: try silent token acquisition first;
on failure, redirect interactively for that scope set and let the navigation
handle it -- don't try to recover in place.
Deployed-vs-local config drift is its own bug class: when two different env vars can each answer "which app is this" (e.g. one injected by your deploy platform, one injected by your build tool), get the precedence order right and know which one actually carries your full scope set -- getting it backwards silently breaks a specific feature only in the deployed build, invisible in local dev where only one var is set. Test the newest feature specifically against the deployed build, not just locally.
$filter that doesn't exist.teamId::channelId, or callId::filename when
one call can produce multiple recording files).If-Match) on any shared blob you sync through
Graph-backed storage, and handle 412 Precondition Failed with a
re-read-and-merge (set-union for additive fields, last-write-wins per key
for scalars).development
Convene the persona panel on the CURRENT conversation / work-in-progress — the plan, design, or decision you've been building in this session. The INLINE counterpart to /council (which forks and runs isolated, so it cannot see the chat). Use when you want the council to critique what we're working on right now.
development
Convene the persona panel (six orthogonal review lenses) on a target — cold independent fan-out, debate-to-consensus, synthesized verdict with recorded dissent and a roster manifest.
tools
Use when building an Amplifier-powered workflow or automation tool and deciding how to expose it — as standalone .dot attractor pipelines (incl. inside the Resolve dot-graph resolver), an importable Python lib, agent-callable tool modules, or a CLI. Covers the four leverage levels, the DRY rule that keeps logic in ONE home, the judgment for which levels a real consumer actually needs (and when adding a level is just ceremony), and the maximally-DRY attractor-only specialization where the .dot pipeline is the sole logic home.
development
User-need reviewer that speaks for the person who isn't in the room — the one who will actually live with what gets built. Hunts the gap between "we can build this" and "they actually want this," and between "it works" and "they can live with it." Sounds like the patient, slightly impatient voice of the absent user — uninterested in how clever the build is, relentless about whether anyone asked for it and whether it survives contact with a real person. Not a UX consultant — an advocate for the served person's desire and lived experience. A lens for any checkpoint — brainstorm, design, plan, implement, debug, or review — not just design. Use when: a feature is being built because it's buildable rather than wanted, the happy path is celebrated while the recovery path is missing, or nobody can name the person this serves — any time the worry is "does the person we serve actually want this, and can they live with it?"