# Granola for Kody Give any Kody agent access to your [Granola](https://www.granola.ai) meeting notes: search them by keyword or ask questions in plain language, list meetings, read AI summaries, attendees and calendar details, pull full transcripts, browse folders, and — with explicit confirmation — manage webhooks and legal holds. Works with several Granola accounts or workspaces side by side. > Community package. Not affiliated with or endorsed by Granola. It uses Granola's official > [public API](https://docs.granola.ai/introduction) and, optionally, Granola's official > [MCP server](https://docs.granola.ai/help-center/sharing/integrations/mcp). ## Intent Meeting notes are some of the most useful context an agent can have — decisions, action items, who said what — but they are locked in the note-taking app. This package wraps the whole Granola public API as small, strictly validated Kody exports so agents can read notes and transcripts on your behalf, and so automations (CRM sync, follow-ups, wikis, task creation) can build on them. Keys stay in Kody secrets and are injected only for Granola's API host; every change Granola supports through the API (webhooks, legal holds) is a preview until the agent passes `confirm: true`. Success = an agent can answer "what did we decide with Acme last week?" from your real notes without ever seeing your API key. ## What you can do | Area | Exports | Granola endpoint | | --- | --- | --- | | Setup | `.` (overview), `./accounts`, `./verify` | — / probes | | Notes | `./notes/list`, `./notes/get`, `./notes/transcript` | `GET /v1/notes`, `/v1/notes/{id}`, `/v1/notes/{id}/transcript` | | Keyword search | `./notes/search` | scans `GET /v1/notes` + `/v1/notes/{id}` (rate-limited) | | Semantic search | `./mcp/query` | MCP `query_granola_meetings` | | Folders | `./folders/list` | `GET /v1/folders` | | Audit log | `./audit/list` | `GET /v1/audit` | | Webhooks | `./webhooks/list`, `./webhooks/create`, `./webhooks/update`, `./webhooks/delete` | `/v1/webhook-endpoints` | | Legal holds | `./legal-holds/list`, `get`, `create`, `update`, `release`, `custodians/list`, `custodians/add`, `custodians/remove` | `/v1/legal-holds` | | MCP lane | `./mcp/tools`, `./mcp/connect`, `./mcp/call` | `https://mcp.granola.ai/mcp` | The Granola API is read-only for notes: there are no endpoints to create, edit, delete or search notes, and this package does not add any (it never uses the desktop app's private API). Search comes in two kinds: | | Keyword — `./notes/search` | Semantic — `./mcp/query` | | --- | --- | --- | | Finds | Exact words and `"quoted phrases"` (case- and accent-insensitive), `-excluded` terms | Meaning: "what did we promise Acme about pricing?" | | Returns | Ranked notes with hit counts, snippets, note id and web link | A written answer with citation links to the source meetings | | Needs | API key (Business/Enterprise) | One-time Granola sign-in in Kody (any plan) | | Cost | One request per scanned note (default cap 100 newest, max 1000) | One MCP call | | Best for | Names, codes, exact wording, auditable lists | Summaries and questions across many meetings | ## Requirements - A Kody account. - **Granola Business or Enterprise** plan to create API keys ([docs](https://docs.granola.ai/help-center/sharing/integrations/granola-api#who-is-it-for)). On Enterprise, an admin may need to allow API scopes for members. - Webhooks: Business or Enterprise (a personal key manages the endpoints it created). - Audit log: Enterprise, with a separate **Audit API key** ([docs](https://docs.granola.ai/audit)). - Legal holds: Enterprise, enabled by Granola, with a separate **Legal hold API key** ([docs](https://docs.granola.ai/legal-holds)). ### Which key do I need? Granola issues several kinds of key, each on its own page under **Settings → Connectors**. A key only works for its own endpoints; the others answer `404`, and this package explains why. | Key type | Who can create it | Reads / manages | Exports | | --- | --- | --- | --- | | **Personal API key** (*API keys*) | Any member, Business/Enterprise | Your notes (*Personal notes* scope) and workspace-visible notes (*Public notes* scope); webhooks you create | notes, search, folders, webhooks | | **Workspace API key** (*Workspace API keys*) | Workspace admins, Business/Enterprise | Workspace-visible notes and spaces with API access enabled; no private notes; doesn't expire | notes, search, folders, webhooks | | **Audit API key** (*Audit API keys*) | Workspace admins, Enterprise | The audit log only | `./audit/list` | | **Legal hold API key** (*API → Legal hold API keys*) | Workspace admins, Enterprise, once Granola enables it | Legal holds only | `./legal-holds/*` | Most people want a **personal API key**. Save each key under its own account name, e.g. `granola-work`, `granola-work-audit`, `granola-work-legal`; `./verify` reports which kind a secret holds (`keyKind`). - Semantic search / MCP lane: any Granola plan (the free Basic plan sees the last 30 days). No API key. ## Setup ### 1. Create a Granola API key From Granola's [API key guide](https://docs.granola.ai/help-center/sharing/integrations/granola-api#getting-started): 1. Open the Granola **desktop app**. 2. Go to **Settings → Connectors → API keys**. 3. Click **Create new key**, choose the access scopes (**Personal notes**, **Public notes**, or both), then **Generate API Key**. 4. Copy the key (it starts with `grn_`). Workspace admins can instead create a **workspace API key** under **Settings → Connectors → Workspace API keys** (does not expire, reads public notes and spaces with API access enabled). See the same guide. ### 2. Save it in Kody Pick a short account name (lowercase letters, digits, `-`, `_`), e.g. `work` or `acme`. Save the key as a Kody **user secret** named **`granola-`** with allowed host **`public-api.granola.ai`**. This link pre-fills everything except the value (replace `work`): ``` https://kody.codes/account/secrets/new?name=granola-work&allowedHosts=public-api.granola.ai&scope=user ``` Never paste the key into a chat. If the secret already exists but the host is not allowed yet, approve it at `https://kody.codes/connect/secrets?name=granola-work&hosts=public-api.granola.ai` (`./accounts` prints these links for you). ### 3. Verify ```ts import verify from 'kody:@charlesmatte/granola/verify' const r = await verify({ account: 'work' }) // { keyWorks: true, keyKind: 'personal or workspace API key', probes: { notes: { status: 200, … }, audit: { status: 404 }, … } } // audit/legalHolds 404 is expected for a personal key: they need their own key types (see above). ``` ### 4. (Optional) Connect Granola MCP for semantic search `./mcp/query` and `./mcp/call` use Granola's official MCP server, which signs in through the browser instead of an API key. Do this once per Kody account: 1. Open **[kody.codes/account/mcp-servers](https://kody.codes/account/mcp-servers)** and add a server named **`granola`** with URL **`https://mcp.granola.ai/mcp`** (no bearer token). Or ask your agent to run `./mcp/connect({ confirm: true })`, which does the same thing. 2. Kody returns an **authorization link**. Open it, sign in to Granola with the account whose notes you want, and approve access. 3. Check: `./mcp/tools({})` should show `connected: true` and the Granola tools. If Granola later asks to sign in again, reconnect from the server's page on `/account/mcp-servers` (or ask an agent to call Kody's `mcpServerReconnect`). The MCP connection belongs to your Kody account, not to an `account` name; to use a second Granola login, add another server (e.g. `granola-work`) and pass `server: 'granola-work'`. ### Several accounts Every call takes `account`, so add one secret per workspace or key: `granola-work`, `granola-personal`, `granola-acme-audit` (an audit key), `granola-acme-legal` (a legal-hold key), … There is no default account. If you already have a secret with another name, pass `secretName: ''` alongside `account`. ## Examples ```ts import searchNotes from 'kody:@charlesmatte/granola/notes/search' // Keyword search: every term must appear (mode: 'any' for either), phrase kept whole, "internal" excluded const r = await searchNotes({ account: 'work', query: 'acme "next steps" -internal', createdAfter: '2026-09-01' }) // r.results: [{ noteId, title, created_at, web_url, score, hits: { title: 1, summary: 3 }, snippets: [{ field, text }] }] // r.scanned / r.capHit / r.nextCursor tell you how much was searched; pass cursor: r.nextCursor to continue // Also search transcripts (slower: transcripts are large) await searchNotes({ account: 'work', query: 'renewal date', includeTranscripts: true, maxNotes: 50 }) ``` ```ts import askGranola from 'kody:@charlesmatte/granola/mcp/query' // Semantic search (needs the one-time MCP sign-in above) const { answer, citations } = await askGranola({ question: 'What did we promise Acme about pricing last month?' }) // citations: [{ ref: '0', url: 'https://notes.granola.ai/…' }] ``` ```ts import listNotes from 'kody:@charlesmatte/granola/notes/list' import getNote from 'kody:@charlesmatte/granola/notes/get' // Meetings since Sept 1 whose title mentions "acme" (date filter server-side, title filter locally) const { notes } = await listNotes({ account: 'work', createdAfter: '2026-09-01', titleContains: 'acme', limit: 200 }) // One meeting with its summary (Markdown), attendees, calendar event and readable transcript const note = await getNote({ account: 'work', noteId: notes[0].id, includeTranscript: true }) console.log(note.summary_markdown, note.attendees, note.transcript_text) ``` ```ts import transcript from 'kody:@charlesmatte/granola/notes/transcript' import listFolders from 'kody:@charlesmatte/granola/folders/list' const t = await transcript({ account: 'work', noteId: 'not_1d3tmYTlCICgjy' }) // whole transcript, paged const { folders } = await listFolders({ account: 'work' }) // each with a "Parent / Child" path ``` ```ts // Incremental sync: everything edited since the last run const changed = await listNotes({ account: 'work', updatedAfter: '2026-10-01T00:00:00Z', limit: 1000 }) ``` ```ts import createWebhook from 'kody:@charlesmatte/granola/webhooks/create' // Preview first (no request is sent)… const preview = await createWebhook({ account: 'work', url: 'https://example.com/granola', scopes: ['personal'], events: ['note.generated'] }) // …then, only when the user asked for it: const created = await createWebhook({ account: 'work', url: 'https://example.com/granola', scopes: ['personal'], saveSigningSecretAs: 'granola-work-webhook', confirm: true }) ``` ## Export reference All exports take one object, reject unknown keys, and require `account` (except `.`, `./accounts` and `./mcp/*`, which use the MCP connection instead). Lists follow cursors automatically up to `limit`; pass `paginate: false` for one page, or `cursor` to resume. | Export | Input (besides `account`, `secretName?`) | Returns | | --- | --- | --- | | `.` | — | Overview, configured accounts, export list | | `./accounts` | `account?` | `granola-*` secrets with host approval status and setup links | | `./verify` | `probes?` (`notes`, `folders`, `webhooks`, `audit`, `legalHolds`) | Per-endpoint status, `keyWorks`, `keyKind` | | `./notes/list` | `createdAfter?`, `createdBefore?`, `updatedAfter?` (date or ISO), `folderId?`, `titleContains?`, `ownerEmail?`, `limit?` (default 30, max 5000) | `{ notes: [{ id, title, owner, created_at, updated_at }], hasMore, cursor }` | | `./notes/get` | `noteId`, `includeTranscript?`, `transcriptFormat?` (`text`/`items`/`both`) | Note with `summary_markdown`, `attendees`, `calendar_event`, `folder_membership`, `web_url`, private notes (owner keys), `transcript_text`/`transcript` | | `./notes/transcript` | `noteId`, `format?`, `timestamps?`, `limit?` | `{ text?, items?, count }` (pages of 100) | | `./notes/search` | `query`, `mode?` (`all`/`any`), `createdAfter?`, `createdBefore?`, `updatedAfter?`, `updatedBefore?`, `folderId?`, `includeTranscripts?` (false), `includePrivateNotes?` (true), `maxNotes?` (100, ≤1000), `maxResults?` (20, ≤100), `snippetChars?`, `snippetsPerNote?`, `cursor?` | `{ results: [{ noteId, title, created_at, web_url, owner, score, termsMatched, hits, snippets }], scanned, capHit, nextCursor, coverage, fetchErrors, matchedCount }` | | `./folders/list` | `tree?`, `limit?` | `{ folders: [{ id, name, parent_folder_id, path, depth }], tree? }` | | `./audit/list` | `action?` (prefix), `occurredAfter?`, `occurredBefore?`, `limit?` (default 100) | `{ events: [...] }` — [action reference](https://docs.granola.ai/audit-events) | | `./webhooks/list` | — | `{ webhookEndpoints: [...] }` | | `./webhooks/create` ✎ | `url`, `scopes` (`personal`/`public`/`workspace`), `events?`, `folderIds?`, `saveSigningSecretAs?` or `revealSigningSecret?` | Endpoint + signing-secret handling | | `./webhooks/update` ✎ | `webhookId`, any of `url`, `scopes`, `events`, `folderIds`, `enabled` | Updated endpoint | | `./webhooks/delete` ✎ | `webhookId` | `{ id, deleted: true }` | | `./legal-holds/list` | `limit?` | Holds (incl. released) | | `./legal-holds/get` | `holdId` | One hold | | `./legal-holds/create` ✎ | `name`, `coversEntireWorkspace`, `description?`, `custodians?` (emails or `usr_…`) | New hold | | `./legal-holds/update` ✎ | `holdId`, any of `name`, `description`, `coversEntireWorkspace` | Updated hold | | `./legal-holds/release` ✎ | `holdId` | Release result (**terminal**) | | `./legal-holds/custodians/list` | `holdId`, `limit?` | Custodians (incl. removed) | | `./legal-holds/custodians/add` ✎ | `holdId`, `custodians` | Resolved custodians | | `./legal-holds/custodians/remove` ✎ | `holdId`, `custodianId` (`lhc_…`) | Removal result | | `./mcp/tools` | `server?` | MCP connection status and tools | | `./mcp/connect` ✎ | `server?` | Adds Granola MCP to Kody; returns an OAuth `authUrl` | | `./mcp/query` | `question`, `documentIds?`, `server?`, `includeRaw?` | `{ answer, citations: [{ ref, url }], isError }` | | `./mcp/call` | `tool`, `args?`, `server?` | Result of a read-only Granola MCP tool | ✎ = write: previews unless `confirm: true`. ## Safety - **Writes preview by default.** Webhook, legal-hold and MCP-connect exports return a dry run (the exact request, plus the current state where it can be read) unless called with `confirm: true`. `dryRun: true` always wins. Agents should confirm only when the user asked for that exact change. - **Keys never enter the sandbox.** Requests carry `Bearer {{secret:granola-}}`; Kody substitutes the value only for `public-api.granola.ai`. Redirects are not followed. - **Webhook signing secrets** are shown by Granola once. `./webhooks/create` refuses to create an endpoint unless you choose where the secret goes (`saveSigningSecretAs` stores it as a Kody secret — this needs the package to be allowed to write secrets; otherwise it falls back to returning it so it is not lost — or `revealSigningSecret: true`). - **Legal-hold release is permanent**; the preview says so. - Webhook URLs must be public HTTPS; private and local hosts are refused before any request. ## Granola MCP lane Granola's MCP server adds one capability the REST API lacks — `query_granola_meetings`, which answers questions across your meetings — and works on every plan via browser sign-in (no API key). Set it up once (see [step 4](#4-optional-connect-granola-mcp-for-semantic-search)), then use `./mcp/query` for questions or `./mcp/call` for the other tools: ```ts import mcpCall from 'kody:@charlesmatte/granola/mcp/call' await mcpCall({ tool: 'get_account_info' }) ``` Only Granola's documented read-only tools can be called (`query_granola_meetings`, `list_meetings`, `get_meetings`, `list_meeting_folders`, `get_meeting_transcript`, `get_account_info`); `args` is passed through as Granola defines it. Granola's help page lists the tools but not their argument schemas; `./mcp/query` sends `{ query, document_ids? }`. Once connected, Kody's `search({ entity: "mcp-server:granola" })` shows the live schemas. MCP rate limits average about 100 requests/minute. ## Behaviour and limits - Base URL `https://public-api.granola.ai/v1`. Rate limit: bursts of 25 requests, then 5/second; `429` responses are retried with backoff. `./notes/search` paces itself with a token bucket (25 burst, 5/s, 4 parallel fetches); in practice it scans about 3 notes per second, so 100 notes take roughly 30 seconds. Narrow by date or folder rather than raising `maxNotes`. - `./notes/search` only sees notes the key can read, scans the newest notes first, and reports `scanned`, `capHit` and `nextCursor` so a partial scan is never mistaken for "no matches". Notes that fail to load are listed in `fetchErrors` (their titles are still searched). - Only notes with a generated AI summary **and** transcript are returned; in-progress notes 404. - What a key sees depends on its scopes: personal notes, public (workspace-visible) notes, and for workspace keys, spaces with API access enabled. - Large transcripts: `./notes/get` falls back to the paged transcript endpoint on `413 TRANSCRIPT_TOO_LARGE`. - Transcript speakers: Granola returns `Me`/`Them` attribution and sometimes a name. Names can carry meeting-app badges ("External unfamiliar", "Unverified"); `transcript_text` strips them, raw `items` keep Granola's value. - There is no sandbox; Granola suggests testing on a small dedicated folder. ## License MIT — see [LICENSE](./LICENSE).