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.

  • Integrations
  • $microsoft-graph
  • 191 lines
  • 2 files
  • ~2,300 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- microsoft-graph

Installs to ~/.agents/skills/microsoft-graph/ for every project on this machine.

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.

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

--- name: microsoft-graph description: Integrates with Microsoft Graph for Outlook mail and calendar, Teams, OneDrive and SharePoint - Entra ID app registrations, delegated vs application… license: MIT ---

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

SituationPermission typeToken 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 userApplication (e.g. Mail.Send, Sites.Selected)Client credentials with https://graph.microsoft.com/.default
Service needs a few SharePoint sites onlyApplication Sites.Selected + per-site grantClient 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"])
TaskCall
Send mail (confirm with user first)POST /users/{id}/sendMail with {"message": {...}, "saveToSentItems": true}
Reply in threadPOST /users/{id}/messages/{msgId}/reply with comment
Plain-text bodiesHeader Prefer: outlook.body-content-type="text"
Attachments > 3 MBPOST .../attachments/createUploadSession
Incremental mail syncGET /users/{id}/mailFolders/inbox/messages/delta, store @odata.deltaLink
Events in a windowGET /users/{id}/calendarView?startDateTime=...&endDateTime=... (expands recurrences)
Time zone of returned eventsHeader Prefer: outlook.timezone="Europe/Berlin"
Free/busy for many peoplePOST /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.contentType to html and 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}/drives for document libraries.
  • Sharing links (createLink) and permission grants are outward-facing; confirm scope (organization vs anonymous) with the user.
  • conflictBehavior: fail, replace or rename. Pick deliberately; replace overwrites.

Batching and throttling

  • POST /$batch takes up to 20 requests; each inner response has its own status and may be 429 individually. Use dependsOn only when order matters.
  • Throttling is per app per tenant and per mailbox; honour Retry-After exactly and lower concurrency (4 parallel requests per mailbox is a good ceiling for Outlook).
  • Use $select everywhere: 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 as text/plain within 10 seconds or creation fails.
  • Check clientState on 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 handle reauthorizationRequired lifecycle events.
  • Notifications without resource data are signals: fetch the item, or run a delta query.

Error decoder

ErrorCauseFix
401 InvalidAuthenticationTokenToken for another audience, expired, or v1 vs v2 endpoint mix-upRequest https://graph.microsoft.com/.default
403 Authorization_RequestDeniedPermission missing or no admin consentGrant and consent, then fetch a new token
403 ErrorAccessDenied on a mailboxExchange RBAC / access policy excludes itAdd mailbox to the scope
404 on /me with app-only tokenNo signed-in user in client credentialsUse /users/{id}
400 InefficientFilter$filter/$orderby combinationAlign 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 size and file.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.yamlgenerated from this page

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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem