Granola Personal Data Export
Extract your own Granola.ai data — meetings, notes, transcripts, AI summaries — via their REST API using the auth tokens stored locally by the Granola Mac app. For personal data recovery only. Reads tokens from your already-logged-in session; never logs in or scrapes.
Why this exists
Granola already has two official ways to get your data out:
- CSV export (Settings → Profile → "Generate CSV") — enabled by default on Basic (free) and Business plans, admin-toggle on Enterprise. Returns titles + short summaries only, emailed within hours, rate-limited to 1 per 24h. No transcripts, no AI panel content, no attachments.
- Personal API (documented at
docs.granola.ai/introduction) —GET /v1/notes, full notes with transcript + summary as JSON, auth viagrn_API keys. Business + Enterprise plans only.
If you're on Business+ and want JSON note access, use Granola's official API — it's the supported path with stable contracts.
This skill fills the gaps neither covers:
- Free-tier full history. Basic plan's UI shows only the last 30 days, but the underlying API returns everything. This skill uses that path.
- Markdown rendering per meeting. Granola's API returns JSON; this renders one Markdown file per meeting plus a sortable index.
- Auxiliary data. Folders, recipes, panel templates, calendar links, attendee profiles — none in
/v1/notes, all captured here. - AI panels with original + edited content. The full ProseMirror panel content, including the AI's first draft before user edits.
- Claude Code skill packaging. Trigger phrases auto-load this skill; AI coding agents can run the export end-to-end.
About audio: Granola doesn't store audio recordings — they're transcribed in real time and then deleted, by design (privacy choice). So no tool, including this one, can recover the actual audio file. The audio_file_handle field on documents is a vestige of upload processing, not a permanent storage pointer.
Prior art worth knowing: theantichris/granola, magarcia/granola-cli, wassimk/granary, pedramamini/granola-mcp, and Joseph Thacker's Granola → Obsidian writeup. Different approaches (cache parsing vs. API calls vs. MCP). This skill's differentiators: full Claude Code skill packaging, defensive paid-tier endpoint coverage, idempotent backups.
Quick start
# Full export — meetings + transcripts + AI summaries + attachments
# + auxiliary endpoints (folders, recipes, templates, people, etc.)
python3 ~/.claude/skills/granola-export/scripts/extract.py
# Output:
# ~/reference/granola/extracted/my-data/
# ├── docs.json (all document metadata, 46 fields per doc)
# ├── transcripts/<id>.json (raw word-level transcripts)
# ├── panels/<id>.json (raw AI summary panels, ProseMirror JSON)
# ├── attachments/ (downloaded image attachments)
# │ ├── _index.json
# │ └── <attachment-id>.<ext>
# ├── aux/ (single-shot auxiliary endpoints)
# │ ├── recipes.json, panel_templates.json, folders.json,
# │ │ workspaces.json, people.json, subscription.json, etc. (~16 files)
# │ └── rendered/*.md (human-readable summaries)
# └── meetings/ (rendered Markdown — one file per meeting)
# ├── INDEX.md (sortable master index)
# └── YYYY-MM-DD_HHMM_title-slug_<id>.md × N
# To skip the extended fetch (only meetings, no aux/attachments):
python3 scripts/extract.py --no-extended
If the access token is stale, the script prints a clear refresh hint. To refresh:
~/.claude/skills/granola-export/scripts/refresh_token.sh
Auth model (one-page summary)
- Granola uses WorkOS for auth. Cognito tokens also appear in
supabase.jsonbut are legacy and typically long-expired — vestigial from before the WorkOS migration. Use the WorkOS token, not Cognito. - Tokens live in
~/Library/Application Support/Granola/supabase.jsonas JSON-stringified inner JSON (jq -r '.workos_tokens' | jq -r '.access_token'). - WorkOS
access_tokenTTL: ~1 hour. Refresh token long-lived. - Auth header:
Authorization: Bearer <access_token>. - All responses are gzipped — always send
Accept-Encoding: gzipand use--compressed.
Full detail: references/auth.md.
Common operations
Full export (default)
python3 scripts/extract.py
Resume / top up after a partial run
python3 scripts/extract.py # idempotent — skips files already on disk
Single document
python3 -c "from api import *; t = get_transcript('<doc-id>'); print(t)"
Refresh token without re-running export
./scripts/refresh_token.sh
Just the document index (no transcripts/panels)
python3 scripts/extract.py --index-only
Important endpoints (used by this skill)
| Method | Endpoint | Purpose |
|---|---|---|
POST | https://api.granola.ai/v1/hello | Auth probe; returns the user's UUID as plain text |
POST | https://api.granola.ai/v1/get-user-info | Returns full user record (email, workspace_ids, scopes) |
POST | https://api.granola.ai/v2/get-documents | Paginated document list. Body: {"limit": 100, "offset": N} |
POST | https://api.granola.ai/v1/get-document-transcript | Body: {"document_id": "<uuid>"} → array of transcript segments with start_timestamp, text, source (microphone/system) |
POST | https://api.granola.ai/v1/get-document-panels | Body: {"document_id": "<uuid>"} → array of AI summary panels in ProseMirror JSON |
POST | https://api.granola.ai/v1/get-feature-flags | All feature flags for the user (also in local-state.json) |
POST | https://api.granola.ai/v1/get-attachments | File attachments uploaded into a doc |
POST | https://api.granola.ai/v1/get-recipes | Recipes — returns dict with userRecipes, sharedRecipes, publicRecipes, unlistedRecipes, defaultRecipes, recipesUsage |
POST | https://api.granola.ai/v1/get-panel-templates | Default + custom panel templates (returns array) |
POST | https://api.granola.ai/v2/get-document-lists | Folder/list organization. v2 — /v1/get-document-lists returns "Not implemented" |
POST | https://api.granola.ai/v1/get-shared-documents | Documents shared to you by others |
Many more exist but are not needed for personal data export. Full curated list in references/endpoints.md.
Known pitfalls
Real failure modes encountered while building this — handled defensively in the bundled scripts, documented here so you understand what's happening if you hit them.
| Symptom | Cause | Fix (already in scripts) |
|---|---|---|
| HTTP 200 but binary garbage in response | Response is gzipped | Always pass --compressed to curl, or Accept-Encoding: gzip header for libraries that auto-decompress |
"error": "deprecated" | Hitting a v1 endpoint that was superseded | Use /v2/get-documents (not /v1/get-documents-delta) |
"message": "Internal Server Error" after long timeout | Bare {} body where backend expects pagination params | Pass {"limit": 100, "offset": 0} |
'str' object has no attribute 'get' in ProseMirror conversion | Some panels have content as a bare string instead of a dict | The pm_to_md() function in api.py handles strings, lists, dicts, and None defensively |
jq Invalid string: control characters from U+0000 through U+001F | Granola's API responses contain raw newlines/tabs in some string fields (notes content) | Don't try t |