Skip to content
← Public packages

@charlesmatte/granola

Search and read Granola meeting notes, summaries and transcripts from any agent; manage webhooks and legal holds safely.

README.md

289 lines · 17.3 KB · Markdown
Raw

Granola for Kody

Give any Kody agent access to your Granola 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 and, optionally, Granola's official MCP server.

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

AreaExportsGranola endpoint
Setup. (overview), ./accounts, ./verify— / probes
Notes./notes/list, ./notes/get, ./notes/transcriptGET /v1/notes, /v1/notes/{id}, /v1/notes/{id}/transcript
Keyword search./notes/searchscans GET /v1/notes + /v1/notes/{id} (rate-limited)
Semantic search./mcp/queryMCP query_granola_meetings
Folders./folders/listGET /v1/folders
Audit log./audit/listGET /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/callhttps://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/searchSemantic — ./mcp/query
FindsExact words and "quoted phrases" (case- and accent-insensitive), -excluded termsMeaning: "what did we promise Acme about pricing?"
ReturnsRanked notes with hit counts, snippets, note id and web linkA written answer with citation links to the source meetings
NeedsAPI key (Business/Enterprise)One-time Granola sign-in in Kody (any plan)
CostOne request per scanned note (default cap 100 newest, max 1000)One MCP call
Best forNames, codes, exact wording, auditable listsSummaries and questions across many meetings

Requirements

  • A Kody account.
  • Granola Business or Enterprise plan to create API keys (docs). 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).
  • Legal holds: Enterprise, enabled by Granola, with a separate Legal hold API key (docs).
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 typeWho can create itReads / managesExports
Personal API key (API keys)Any member, Business/EnterpriseYour notes (Personal notes scope) and workspace-visible notes (Public notes scope); webhooks you createnotes, search, folders, webhooks
Workspace API key (Workspace API keys)Workspace admins, Business/EnterpriseWorkspace-visible notes and spaces with API access enabled; no private notes; doesn't expirenotes, search, folders, webhooks
Audit API key (Audit API keys)Workspace admins, EnterpriseThe audit log only./audit/list
Legal hold API key (API → Legal hold API keys)Workspace admins, Enterprise, once Granola enables itLegal 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:

  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-<account> 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
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 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: '<name>' alongside account.

Examples

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 })
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/…' }]
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)
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
// Incremental sync: everything edited since the last run
const changed = await listNotes({ account: 'work', updatedAfter: '2026-10-01T00:00:00Z', limit: 1000 })
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.

ExportInput (besides account, secretName?)Returns
.—Overview, configured accounts, export list
./accountsaccount?granola-* secrets with host approval status and setup links
./verifyprobes? (notes, folders, webhooks, audit, legalHolds)Per-endpoint status, keyWorks, keyKind
./notes/listcreatedAfter?, createdBefore?, updatedAfter? (date or ISO), folderId?, titleContains?, ownerEmail?, limit? (default 30, max 5000){ notes: [{ id, title, owner, created_at, updated_at }], hasMore, cursor }
./notes/getnoteId, includeTranscript?, transcriptFormat? (text/items/both)Note with summary_markdown, attendees, calendar_event, folder_membership, web_url, private notes (owner keys), transcript_text/transcript
./notes/transcriptnoteId, format?, timestamps?, limit?{ text?, items?, count } (pages of 100)
./notes/searchquery, 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/listtree?, limit?{ folders: [{ id, name, parent_folder_id, path, depth }], tree? }
./audit/listaction? (prefix), occurredAfter?, occurredBefore?, limit? (default 100){ events: [...] } — action reference
./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, enabledUpdated endpoint
./webhooks/delete ✎webhookId{ id, deleted: true }
./legal-holds/listlimit?Holds (incl. released)
./legal-holds/getholdIdOne hold
./legal-holds/create ✎name, coversEntireWorkspace, description?, custodians? (emails or usr_…)New hold
./legal-holds/update ✎holdId, any of name, description, coversEntireWorkspaceUpdated hold
./legal-holds/release ✎holdIdRelease result (terminal)
./legal-holds/custodians/listholdId, limit?Custodians (incl. removed)
./legal-holds/custodians/add ✎holdId, custodiansResolved custodians
./legal-holds/custodians/remove ✎holdId, custodianId (lhc_…)Removal result
./mcp/toolsserver?MCP connection status and tools
./mcp/connect ✎server?Adds Granola MCP to Kody; returns an OAuth authUrl
./mcp/queryquestion, documentIds?, server?, includeRaw?{ answer, citations: [{ ref, url }], isError }
./mcp/calltool, 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-<account>}}; 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), then use ./mcp/query for questions or ./mcp/call for the other tools:

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.