The cerebro-graph-ingest Edge Function pulls daily from Microsoft 365 (mail, calendar, OneNote, OneDrive/SharePoint files) and saves anything "worth learning" as thoughts rows. An AI gatekeeper decides save vs skip on a per-item basis, biased toward undersaving.
- 4 sources: Outlook mail (
graph-mail), calendar events (graph-event), OneNote pages (graph-onenote), OneDrive + SharePoint files (graph-file). - AI gatekeeper:
gpt-4o-mini(via OpenRouter) classifies each item; only those flagged "save" become thoughts. Calendar events with non-emptybodyPreviewOR more than 2 attendees are always saved regardless of the classifier. - Hybrid storage: AI-generated summary lands in
content(embedded withtext-embedding-3-small); raw Graph metadata is preserved in JSONB. - Action items are extracted into
metadata.action_items[]and surface in the next daily digest. - People are extracted by the AI from message bodies (avoids the
People.Read.Alldelegated-only restriction). - Dedup:
source_message_id=<source>:<itemId>(e.g.graph-mail:AAMkAD...). - High-water mark: per-source watermark stored in
graph_ingest_state; only items newer than the last successful run are pulled. Calendar uses a fixednow-1dtonow+2dwindow. - Schedule: daily at
0 11 * * *UTC (1 hour before the daily digest at 12:00 UTC).
- Phase 1 (core infrastructure) complete.
- Phase 3 (calendar reminders) already deployed — this reuses the existing Entra ID app registration and these secrets:
GRAPH_TENANT_IDGRAPH_CLIENT_IDGRAPH_CLIENT_SECRETGRAPH_USER_ID— the O365 user whose mailbox/calendar/notes/files will be ingested.
OPENROUTER_API_KEYandSUPABASE_SERVICE_ROLE_KEY(auto-provisioned).- Tenant admin available to grant admin consent for new application permissions.
The existing app needs four additional application (app-only) permissions on Microsoft Graph.
-
Entra admin center → Identity → Applications → App registrations → open the app you used for Teams / calendar reminders.
-
API permissions → Add a permission → Microsoft Graph → Application permissions.
-
Add each of the following (search by name, tick the checkbox, Add permissions):
Permission Why Mail.ReadRead user mail Calendars.ReadRead user calendar events Notes.Read.AllRead OneNote pages Files.Read.AllRead OneDrive + SharePoint files Calendars.ReadWriteshould already be present from the Teams / reminders integration — leave it alone.
Why application permissions, not delegated? The function runs unattended via pg_cron with no user session. Application permissions use the client-credentials grant (
scope=https://graph.microsoft.com/.default).
Application permissions require tenant admin consent before they're usable.
Option A — portal: App registration → API permissions → Grant admin consent for {tenant} → confirm. Each row should flip to a green check under Status.
Option B — direct URL (open in a tenant admin browser session):
https://login.microsoftonline.com/{tenant-id}/v2.0/adminconsent?client_id={client-id}&scope=https://graph.microsoft.com/.default
Replace {tenant-id} with GRAPH_TENANT_ID and {client-id} with GRAPH_CLIENT_ID. After consent, you'll be redirected to the app's reply URL — the redirect itself confirms success.
From any shell with curl, fetch a token and call Graph as the configured user:
TENANT={tenant-id}
CLIENT={client-id}
SECRET={client-secret}
USER={graph-user-id-or-upn}
TOKEN=$(curl -s -X POST "https://login.microsoftonline.com/$TENANT/oauth2/v2.0/token" \
-d "client_id=$CLIENT" -d "client_secret=$SECRET" \
-d "scope=https://graph.microsoft.com/.default" \
-d "grant_type=client_credentials" | jq -r .access_token)
curl -s "https://graph.microsoft.com/v1.0/users/$USER/messages?\$top=1" \
-H "Authorization: Bearer $TOKEN" | jq '.value[0].subject'A subject line means consent + permissions are wired up. 401 = missing/invalid token, 403 = permission not consented, empty value = wrong GRAPH_USER_ID or empty mailbox.
npx supabase db query --linked < schemas/core/011-graph-ingest.sql
npx supabase db query --linked < schemas/core/012-graph-ingest-cron.sql011-graph-ingest.sqlcreates thegraph_ingest_statetable (sourcePK,last_ingested_at,updated_at), seeds rows formail/event/onenote/file, and applies aservice_role-only RLS policy.012-graph-ingest-cron.sqlschedules the pg_cron jobcerebro-graph-ingest-dailyat0 11 * * *UTC.
cp integrations/cerebro-graph-ingest/index.ts supabase/functions/cerebro-graph-ingest/index.ts
cp integrations/cerebro-graph-ingest/deno.json supabase/functions/cerebro-graph-ingest/deno.json
npx supabase functions deploy cerebro-graph-ingest --no-verify-jwtNo new secrets are needed — the function reuses existing GRAPH_*, OPENROUTER_API_KEY, and SUPABASE_SERVICE_ROLE_KEY.
Trigger an on-demand mail ingest:
curl -X POST "https://livdhnxdbnhxxxlgcoge.supabase.co/functions/v1/cerebro-graph-ingest" \
-H "x-brain-key: $BRAIN_ACCESS_KEY" \
-H "Content-Type: application/json" \
-d '{"source":"mail"}'Expected response shape:
{
"ok": true,
"results": {
"mail": { "pulled": 12, "saved": 3, "skipped": 9 }
}
}To run all four sources at once: -d '{"source":"all"}'.
Verify rows landed:
npx supabase db query --linked \
"SELECT metadata->>'source' AS source, count(*) FROM thoughts \
WHERE metadata->>'source' LIKE 'graph-%' GROUP BY 1"npx supabase db query --linked \
"SELECT jobname, schedule, active FROM cron.job WHERE jobname = 'cerebro-graph-ingest-daily'"Expected: one row, schedule 0 11 * * *, active = true. The daily digest runs at 0 12 * * * UTC, giving the ingest a 1-hour window to complete.
All reused from earlier phases — none new.
| Variable | Purpose |
|---|---|
GRAPH_TENANT_ID |
Entra tenant ID |
GRAPH_CLIENT_ID |
App registration client ID |
GRAPH_CLIENT_SECRET |
App registration secret |
GRAPH_USER_ID |
UPN or object ID of the M365 user to ingest |
OPENROUTER_API_KEY |
Embeddings + classifier |
SUPABASE_SERVICE_ROLE_KEY |
Cron auth (Authorization: Bearer …) |
BRAIN_ACCESS_KEY |
Manual invocation auth (x-brain-key) |
{ "source": "all" | "mail" | "event" | "onenote" | "file" }all ingests every source sequentially. Single-source mode is useful for testing or replaying one channel.
Stored as metadata.source on every saved thought. Used by the daily digest to bucket items.
| Tag | Source | Window |
|---|---|---|
graph-mail |
Outlook mail | High-water mark from graph_ingest_state |
graph-event |
Calendar | Fixed now-1d to now+2d (always re-pulled) |
graph-onenote |
OneNote pages | High-water mark |
graph-file |
OneDrive + SharePoint files | High-water mark |
Each ingest issues one classifier call per item plus one embedding per saved item. At ~50 items/day across all four sources with a ~30% save rate, expect well under $0.05/month in OpenRouter charges on top of Phase 1 baseline.
| Symptom | Likely cause | Fix |
|---|---|---|
401 Unauthorized from Graph |
Token request failed — wrong client/secret/tenant | Re-verify GRAPH_* secrets; rotate the client secret if expired |
403 Forbidden from Graph |
Permission not consented | Re-run admin consent (Step 2); confirm green check on each permission |
AADSTS65001 in logs |
User or admin has not consented to the application | Run admin consent URL in a tenant-admin browser session |
Empty value array on every endpoint |
Wrong GRAPH_USER_ID |
Confirm UPN/object ID with GET /users/{id} |
Function returns 200 but saved: 0 for everything |
Classifier rejecting all items (expected on quiet days) | Lower the bar by editing the classifier prompt, or check the logs for skip reasons |
| Duplicate rows appearing | source_message_id not set |
Confirm the function is the one deployed (older versions lacked dedup) |
| Cron not firing at 11:00 UTC | pg_cron / pg_net extensions disabled |
Enable both in Database → Extensions in the Supabase dashboard, then re-run 012-graph-ingest-cron.sql |
| Calendar items always save even when "boring" | This is by design (always-save rule) | Tighten by editing the calendar branch in index.ts |
- Function reference
- Daily digest setup — surfaces graph-ingested rows in the next morning's digest
- Microsoft Graph permissions reference