Granola Sync
What
A standalone script (granola_sync.py) that pulls meeting notes and full transcripts from Granola's official Enterprise API and writes one Markdown file per meeting into a local transcripts directory. It is incremental (only fetches new/changed notes), uses an API key (no scraping or local-app token extraction), and depends on nothing but the Python 3 standard library.
Who
Authored by the pipeline maintainer. Consumed by the action extractor (which reads the Markdown files) and by any agent asked to rebuild the sync. The human only touches it to supply an API key once and to set the output directory.
Why
Granola exposes an official read API (public-api.granola.ai/v1) gated by a workspace API key. Using it avoids the fragile alternative of reading the desktop app's local credential store, which is encrypted and changes between app versions. The API returns the verbatim transcript (speaker-attributed, timestamped) plus the AI summary — strictly richer than the summary-only notes the desktop app persists locally. The script keeps a small state file so that after the first full backfill it only fetches notes updated since the last run, keeping each run fast and well under the rate limit.
Bounds
- Not a transcriber. It fetches notes that already exist; it does not record audio or generate transcripts.
- Not a two-way sync. Read-only. It never writes back to the provider.
- Not scope-elevating. It returns only what the API key's scope allows (e.g. notes the key's owner owns or that are shared with them). It cannot see notes the key cannot.
- Not a general HTTP client. It speaks exactly two endpoints: list notes and get note.
- Not responsible for downstream parsing. It defines the Markdown shape (see Interface); interpreting it is the extractor's job.
Gotchas
- The key scope determines coverage. A "personal notes" key returns notes you own and notes shared with you; a "public/workspace" key returns workspace-shared notes. Pick the scope that matches what you want to capture; they are different sets.
- First run vs incremental is decided by the state file. Delete
sync_state.jsonto force a full re-pull. With the state file present, only notes with a newerupdated_at(or created afterlast_run) are fetched. - The window bounds what's fetched, not what's already on disk. Setting
GRANOLA_SINCElater does not delete previously-synced older files; it only stops fetching below the floor. Narrowing the window never removes data. To prune old transcripts, delete the files. - A floor plus incremental can still re-fetch an old, recently-edited note unless the floor is enforced.
updated_after = last_runwould surface a 2-year-old note edited yesterday; thecreated_at < GRANOLA_SINCEskip is what keeps it out. Apply the floor in both paths. updated_aftercatches edits, not just new notes. A note edited after the last run is re-fetched and its file overwritten — intended.- Rate limit is real. The API allows a few requests/second; the script paces calls and backs off on HTTP 429. Don't remove the delay.
- Filenames embed a short id to avoid collisions. Two meetings with the same title on the same day differ by the trailing
[shortid]. Downstream code must treat the filename as opaque and read the frontmatter for identity. - iCloud/synced output dirs lag. If the transcripts dir is a cloud-synced folder, files appear locally immediately but may take time to propagate to other devices. Not a sync-script bug.
How
- Resolve the API key:
GRANOLA_API_KEYenv, else read${GS_CONFIG_DIR}/granola_api_key(chmod 600). - Load state from
${GS_DATA_DIR}/granola_sync_state.json. - Determine the window:
- First run (no state):
created_after = GRANOLA_SINCEif set, otherwise unbounded (full history). - Incremental (state present):
updated_after = last_run. - In both cases apply
created_before = GRANOLA_UNTILif set, and treatGRANOLA_SINCEas a persistent floor: skip any note whosecreated_atis earlier than it. PaginateGET /v1/notes?page_size=30&…&cursor=…untilhasMoreis false, collecting(id, updated_at, created_at).
- First run (no state):
- For each note whose
updated_atdiffers from state,GET /v1/notes/{id}?include=transcript. - Render Markdown (see Interface) and write to
${GS_TRANSCRIPTS_DIR}/{YYYY-MM-DD} - {safe title} [{shortid}].md. - Record
synced[id] = updated_at; setlast_run; save state.
All requests send Authorization: Bearer {key}. Pace requests (~4/s) and retry 429s with exponential backoff.
Example
A first run with a freshly minted personal-scope key, no state file: the script lists 312 notes across 11 pages, fetches each with include=transcript, and writes 312 files. A subsequent run two days later lists with updated_after=<last_run>, finds 4 notes (3 new meetings, 1 edited), writes/overwrites 4 files, and updates the state. A representative output file 2026-01-27 - Quarterly Review [a1b2c3d4].md contains frontmatter (granola_id: not_…, created_at: 2026-01-27T15:30:00Z), an attendee line, a ## Summary section, and a ## Transcript section with lines like **Me** [15:31:02] … / **Them** [15:31:08] ….
Calibration
- Base URL:
https://public-api.granola.ai/v1. PAGE_SIZE = 30(API maximum).REQ_DELAY = 0.25seconds between calls (~4 req/s, under the ~5/s limit).MAX_RETRIES = 5, exponential backoff on 429/network errors.- Sync window:
GRANOLA_SINCE/GRANOLA_UNTIL(ISO dates) both unset by default → first run pulls full history. SetGRANOLA_SINCEto bound the backfill (e.g. last 90 days) and as a permanent floor. Incremental always usesupdated_after = last_run. - Speaker label mapping: source
microphone→Me,speaker→Them. - Output filename:
{date} - {title sanitised to [\w\s-], ≤80 chars} [{first 8 chars of id}].md.
Interface
Provider API (exact field names). Base https://public-api.granola.ai/v1, Authorization: Bearer <key>.
GET /v1/notes — query params: created_before, created_after, updated_after (ISO date or date-time), cursor (string), page_size (1–30, default 10). Response:
{
"notes": [
{ "id": "not_…", "object": "note", "title": "…",
"owner": { "name": "…", "email": "…" },
"created_at": "ISO8601", "updated_at": "ISO8601" }
],
"hasMore": true,
"cursor": "…|null"
}
GET /v1/notes/{note_id}?include=transcript — note_id matches ^not_[A-Za-z0-9]{14}$. Response:
{
"id": "not_…", "object": "note", "title": "…|null",
"owner": { "name": "…", "email": "…" },
"created_at": "ISO8601", "updated_at": "ISO8601",
"calendar_event": { "event_title": "…", "invitees": [{ "email": "…" }],
"organiser": "…", "calendar_event_id": "…",
"scheduled_start_time": "ISO8601", "scheduled_end_time": "ISO8601" },
"attendees": [ { "name": "…", "email": "…" } ],
"folder_membership": [ { "id": "fol_…", "object": "folder", "name": "…" } ],
"summary_text": "…", "summary_markdown": "…|null",
"transcript": [ { "speaker": { "source": "microphone|speaker" },
"text": "…", "start_time": "ISO8601", "end_time": "ISO8601" } ] | null
}
Pagination: follow cursor while hasMore is true. Incremental key: a note is changed iff its updated_at differs from the stored value. transcript is null when a note has none; summary_markdown may be null (fall back to summary_text). Speaker source is exactly microphone (the key owner) or speaker (everyone else). Defensive note: treat these names as the contract, but a robust implementation isolates them in small field-accessor helpers so a future API rename is a one-line change.
Each transcript file MUST be:
---
granola_id: <provider id>
title: "<title>"
created_at: <ISO8601>
updated_at: <ISO8601>
owner: "<name> <email>" # optional
---
# <title>
*<scheduled_start> - <scheduled_end>* # optional
**Attendees:** <comma-separated names> # optional
## Summary
<markdown summary>
## Transcript
**Me** [HH:MM:SS] <utterance>
**Them** [HH:MM:SS] <utterance>
...
State file ${GS_DATA_DIR}/granola_sync_state.json:
{ "synced": { "<note_id>": "<updated_at>" }, "last_run": "<ISO8601 Z>" }
CLI: python3 granola_sync.py [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--full]. Flags override GRANOLA_SINCE/GRANOLA_UNTIL; --full ignores the state file and re-pulls the whole window. No required args. Exit non-zero only on unrecoverable auth failure.
Acceptance
- With a valid key and empty state, a run creates one
.mdfile per accessible note, each containing a## Transcriptsection with at least one speaker-attributed line for notes that have a transcript. - A second immediate run writes zero files (nothing changed) and updates
last_run. - With
GRANOLA_SINCEset, the first run fetches only notes created on/after that date; withGRANOLA_UNTILalso set, only notes within the window — and no note created before the floor is ever written, even on later incremental runs. - Editing one note upstream then running re-writes exactly that one file.
- An invalid/expired key causes a clean exit with a clear message and no partial files.
- No transcript content is ever sent to any third party; the only outbound calls are to the provider API.
Scope
Covers fetching and persisting transcripts as Markdown, and the sync state. Does not cover scheduling (see PIPELINE_SCHEDULING_CONTEXT.md) or any interpretation of transcript content (see ACTION_EXTRACTION_CONTEXT.md).
Format: https://contextcapsules.com/v2/CONTEXT_CAPSULES.md Sensitivity: Public Status: Active References: ACTION_EXTRACTION_CONTEXT.md, PIPELINE_SCHEDULING_CONTEXT.md Owner: Project maintainer Lineage:
- 2026: Implemented against the official provider API after an earlier local-token approach proved fragile across app updates.
- 2026: Interface hardened with exact request params and response field names after a clean-room build had to infer them — closes the gap between "contract pinned" and "field names pinned".
- 2026: Added a configurable sync window (
GRANOLA_SINCE/GRANOLA_UNTIL,--since/--until,--full) after a clean-room build surfaced that the spec only allowed full-history-or-incremental, with no bounded time range.