Microsoft Graph skill for Codex
Outlook, Teams and OneDrive with least privilege. Codex chooses delegated or application permissions on purpose, restricts app mailbox and SharePoint access instead of granting tenant-wide rights, and acquires tokens with MSAL. Calls page through @odata.nextLink, honour Retry-After, upload large files in 320 KiB-aligned chunks and keep subscriptions renewed. A 401/403 decoder maps Graph errors to the missing claim or consent.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- microsoft-graph Installs to ~/.agents/skills/microsoft-graph/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install microsoft-graph The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project microsoft-graph Installs to .agents/skills/microsoft-graph/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/microsoft-graph.zipunzip -o microsoft-graph.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) microsoft-graph PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $microsoft-graph, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Integrates with Microsoft Graph for Outlook mail and calendar, Teams, OneDrive and SharePoint - Entra ID app registrations, delegated vs application permissions, MSAL token acquisition, scoping app mailbox access, paging with @odata.nextLink, $batch, throttling, delta queries, large-file upload sessions and change-notification subscriptions. Use when asked to read or send Outlook mail from code, sync calendars, upload to OneDrive or SharePoint, post to Teams, or debug 401/403 errors from graph.microsoft.com.
Use it when
- You need Outlook mail, calendar, Teams, OneDrive or SharePoint data from code
- A background service must call Graph without a signed-in user
- Large files must be uploaded to OneDrive or SharePoint
- Graph returns 401, 403 or throttling errors
Use something else when
- You need the Entra ID app registration and auth flow design itself: use azure-entra-id
- The mailbox or files are in Google Workspace: use gmail-api
How to check the result
Decode the access token and confirm its roles or scp claims match the least-privilege set, then read back the sent message or uploaded file hash.
The mistake it prevents
Granting Mail.Send as an application permission without an Exchange scope, which lets the app send as every mailbox in the tenant.
Done well means: the app uses the narrowest permission type and scope for the job, application permissions to mailboxes or sites are restricted to the resources the job needs, every collection is paged to the end, throttling is honoured, and nothing is sent or posted without the user confirming it.
Permission model first
| Situation | Permission type | Token flow |
|---|---|---|
| A signed-in user acts on their own data (web app, CLI tool) | Delegated (e.g. Mail.Read, Files.ReadWrite) | Auth code + PKCE, or device code for CLIs |
| Background service, no user | Application (e.g. Mail.Send, Sites.Selected) | Client credentials with https://graph.microsoft.com/.default |
| Service needs a few SharePoint sites only | Application Sites.Selected + per-site grant | Client credentials |
Application permissions need tenant admin consent and apply tenant-wide unless restricted.
For Exchange mailboxes, restrict with RBAC for Applications in Exchange Online (the successor
to application access policies) so Mail.Send cannot send as the CEO. For SharePoint, prefer
Sites.Selected over Sites.ReadWrite.All.
Prefer certificate credentials or managed identity / workload identity federation over client
secrets for production. Secrets expire (max 2 years) and outages from expired secrets are common.
Entra ID registration details live in $azure-entra-id.
Before writing code
- [ ] Read AGENTS.md for the HTTP client, secret store and language
- [ ] Confirm tenant ID, client ID, permission type and the exact permissions granted
- [ ] Confirm admin consent was granted (Entra admin center -> App registrations -> API permissions)
- [ ] Decide SDK vs raw HTTP: raw HTTP + MSAL for scripts, Graph SDK for large apps
- [ ] Note: every call needs network; ask before running in an offline sandbox
Tokens with MSAL
import os, msal, requests, time
app = msal.ConfidentialClientApplication(
client_id=os.environ["GRAPH_CLIENT_ID"],
authority=f"https://login.microsoftonline.com/{os.environ['GRAPH_TENANT_ID']}",
client_credential=os.environ["GRAPH_CLIENT_SECRET"], # or {"private_key": ..., "thumbprint": ...}
)
def token():
r = app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
if "access_token" not in r:
raise RuntimeError(r.get("error_description"))
return r["access_token"] # MSAL caches until near expiry
G = "https://graph.microsoft.com/v1.0"
def graph(method, url, **kw):
url = url if url.startswith("http") else G + url
extra = kw.pop("headers", {})
for attempt in range(6):
r = requests.request(method, url, headers={"Authorization": f"Bearer {token()}", **extra},
timeout=60, **kw)
if r.status_code in (429, 503, 504):
time.sleep(int(r.headers.get("Retry-After", 2 ** attempt)))
continue
r.raise_for_status()
return r.json() if r.content else None
raise RuntimeError("throttled too long")
def pages(url, **kw):
while url:
data = graph("GET", url, **kw)
yield from data.get("value", [])
url = data.get("@odata.nextLink") # use as-is; do not rebuild
kw.pop("params", None) # nextLink already carries the query
Delegated CLI tools: msal.PublicClientApplication(...).acquire_token_interactive(scopes=["Mail.Read"])
or the device code flow, with a persistent token cache outside the repo.
Inspect a token’s roles (application) or scp (delegated) claims by decoding the JWT
payload locally. A 403 with the right-looking code usually means the claim is missing.
Outlook mail and calendar
uid = "[email protected]"
for m in pages(f"/users/{uid}/mailFolders/inbox/messages",
params={"$select": "id,subject,from,receivedDateTime,hasAttachments",
"$filter": "isRead eq false", "$top": "50",
"$orderby": "receivedDateTime desc"}):
print(m["receivedDateTime"], m["subject"])
| Task | Call |
|---|---|
| Send mail (confirm with user first) | POST /users/{id}/sendMail with {"message": {...}, "saveToSentItems": true} |
| Reply in thread | POST /users/{id}/messages/{msgId}/reply with comment |
| Plain-text bodies | Header Prefer: outlook.body-content-type="text" |
| Attachments > 3 MB | POST .../attachments/createUploadSession |
| Incremental mail sync | GET /users/{id}/mailFolders/inbox/messages/delta, store @odata.deltaLink |
| Events in a window | GET /users/{id}/calendarView?startDateTime=...&endDateTime=... (expands recurrences) |
| Time zone of returned events | Header Prefer: outlook.timezone="Europe/Berlin" |
| Free/busy for many people | POST /users/{id}/calendar/getSchedule |
$filter and $orderby on messages must use compatible properties (ordering by a property
that is not also first in the filter returns InefficientFilter). When in doubt, filter by
receivedDateTime and order by it.
Teams
- Posting as a user to a channel needs delegated
ChannelMessage.Send. Application permission posting is limited to migration scenarios; for app-initiated notifications use a bot (Bot Framework / Teams SDK) or a Workflows webhook. - Reading all channel messages app-only uses protected APIs that require Microsoft approval and can be metered; prefer change notifications scoped to specific teams.
- Messages are HTML; set
body.contentTypetohtmland escape user input.
OneDrive and SharePoint files
# Small file (simple upload is limited to a few MB in practice; use sessions above ~4 MB)
graph("PUT", f"/users/{uid}/drive/root:/Reports/q3.csv:/content",
data=open("q3.csv", "rb"), headers={"Content-Type": "text/csv"})
# Large file: upload session, chunks are multiples of 320 KiB and under 60 MiB
sess = graph("POST", f"/users/{uid}/drive/root:/Backups/db.tar.gz:/createUploadSession",
json={"item": {"@microsoft.graph.conflictBehavior": "replace"}})
CHUNK = 320 * 1024 * 32 # 10 MiB
size = os.path.getsize("db.tar.gz")
with open("db.tar.gz", "rb") as f:
for start in range(0, size, CHUNK):
data = f.read(CHUNK)
end = start + len(data) - 1
r = requests.put(sess["uploadUrl"], data=data, timeout=300, # no Authorization header
headers={"Content-Range": f"bytes {start}-{end}/{size}"})
r.raise_for_status()
- SharePoint site lookup:
GET /sites/contoso.sharepoint.com:/sites/Finance, then/sites/{siteId}/drivesfor document libraries. - Sharing links (
createLink) and permission grants are outward-facing; confirm scope (organizationvsanonymous) with the user. conflictBehavior:fail,replaceorrename. Pick deliberately;replaceoverwrites.
Batching and throttling
POST /$batchtakes up to 20 requests; each inner response has its own status and may be 429 individually. UsedependsOnonly when order matters.- Throttling is per app per tenant and per mailbox; honour
Retry-Afterexactly and lower concurrency (4 parallel requests per mailbox is a good ceiling for Outlook). - Use
$selecteverywhere: smaller payloads, fewer throttles.
Change notifications
sub = graph("POST", "/subscriptions", json={
"changeType": "created",
"notificationUrl": "https://hooks.example.com/graph",
"lifecycleNotificationUrl": "https://hooks.example.com/graph/lifecycle",
"resource": f"/users/{uid}/mailFolders('inbox')/messages",
"expirationDateTime": "2026-10-31T10:00:00Z",
"clientState": os.environ["GRAPH_CLIENT_STATE"],
})
- On creation Graph POSTs
?validationToken=...; respond 200 with the token astext/plainwithin 10 seconds or creation fails. - Check
clientStateon every notification; respond 202 quickly and process async. - Subscriptions expire (Outlook resources: a few days at most); renew with
PATCH /subscriptions/{id}on a schedule and handlereauthorizationRequiredlifecycle events. - Notifications without resource data are signals: fetch the item, or run a delta query.
Error decoder
| Error | Cause | Fix |
|---|---|---|
401 InvalidAuthenticationToken | Token for another audience, expired, or v1 vs v2 endpoint mix-up | Request https://graph.microsoft.com/.default |
403 Authorization_RequestDenied | Permission missing or no admin consent | Grant and consent, then fetch a new token |
403 ErrorAccessDenied on a mailbox | Exchange RBAC / access policy excludes it | Add mailbox to the scope |
404 on /me with app-only token | No signed-in user in client credentials | Use /users/{id} |
400 InefficientFilter | $filter/$orderby combination | Align properties |
Verification
- Decode the token and list
roles/scp; they must match the documented least-privilege set. - For mail sends, read back from Sent Items; for uploads, compare
sizeandfile.hashes. - For subscriptions, list
GET /subscriptions, check expiry, and trigger a test event.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Microsoft Graph"
short_description: "Outlook, Teams and OneDrive with least privilege"
default_prompt: "$microsoft-graph Read unread mail in the ops@ shared mailbox and file attachments to SharePoint" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.