microsoft/msgraph-integration-patterns
> 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 skills add https://github.com/microsoft/amplifier-bundle-skills --skill msgraph-integration-patterns
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 onthe 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 channelmessages: /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 hityour 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. transcriptendpoints don't support it) -- declare per-source capabilities and adapt
UI/warnings rather than assuming uniform support.
e.g. Teams chat IDs are MRIs starting with 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.
scopes only (openid, profile, email); a separate app for the actual
Graph content scopes. A leaked login credential then can't pivot to content
access.
*drop* scopes a prior approach needed once you replace that approach (e.g.
dropping calendar/online-meetings scopes entirely once a chat-based
discovery method replaced the calendar-based one -- see §3).
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).
Match /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 deadend 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 readeventDetail[\"@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).
meeting organizer.** The "organizer-only" gate is an artifact of the
/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 resolvedriveId/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=falseto get raw VTT.
401 (a hosted SPA needs the SP token; a browser extension could ride
cookies but a hosted app can't).
/shares/{id}/driveItemreturns 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.
of 38 recordings in a 30-day window. Deep-scan each in-window container
until you pass the window edge or hit a page cap; if the cap fires first,
flag the result truncated: true and say so loudly in the UI -- never
silently incomplete.
padded date-window buffer (in case a container's "last updated" timestamp
was stale) was later disproven with a full-scan oracle across hundreds of
containers -- the timestamp was always current, the buffer was pure
overhead. Removing it cut the scan size substantially.
hostname patterns.** If your app runs across multiple tenants or
environments (production, staging, internal test rings), verify the
actual SharePoint hostname per environment rather than assuming one
wildcard covers all -- each distinct host pattern needs its own
CSP/allowlist entry.
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 + transcriptdownloads share one wildcard (add further host patterns per environment,
see §3's last point).
frame-src entries were added to support an ssoSilent hidden-iframeapproach 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 amisbehaving value.
Retry-After (e.g.~1s doubling, capped ~30s, random jitter to avoid thundering-herd retries).
truncated body.
scan across hundreds of items shouldn't die to a token that expired
mid-scan.
unbounded Promise.all over hundreds of items -- bounds throttling risk.
scope failures as a signal) rather than building custom retry dashboards
before you know if you even need them.
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 hiddenssoSilent iframe returning to the app's own origin. Added 'self' to
frame-src. Loop persisted.
host in frame-src to ride an existing session cookie. Added it. Loop
persisted.
third-party session cookies in same-frame contexts, *and* the identity
provider refuses to render its login page in an iframe at all
(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.
platform's own catch-all "require auth on every route" rule intercepted
MSAL's own redirect-return navigation (which carries the auth code in a
URL fragment) and stripped the fragment before MSAL could read it -- so
MSAL never established a session. Fix: add an anonymous callback route
(e.g. /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.
future session with a similar symptom doesn't re-litigate the same wrong
hypothesis (CSP) again.
module top-level blocked first paint for the iframe's multi-second
timeout -- a white-screen bug, not a loop. Fix: make it non-blocking with
a visible "Signing in..." placeholder.
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.
different Graph surfaces and scope requirements** even though they feel
like the same product area to a user -- investigate each independently,
don't assume parity.
exception -- "page newest-first, stop once a whole page is provably older
than your window" beats hoping for a $filter that doesn't exist.
"no access" for that item** -- pattern-match 403/forbidden/access-denied
and convert to a skip or a labeled UI state, so one inaccessible item
doesn't abort enumeration of everything else.
spanning a hierarchy (e.g. teamId::channelId, or callId::filename when
one call can produce multiple recording files).
consent removes the security argument for going narrow** -- the narrower
scope's interactive picker can add UX friction with no remaining benefit.
conditional writes (ETags/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).
Take microsoft/msgraph-integration-patterns from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.