@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 · MarkdownGranola 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
| 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). 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 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:
- Open the Granola desktop app.
- Go to Settings → Connectors → API keys.
- Click Create new key, choose the access scopes (Personal notes, Public notes, or both), then Generate API Key.
- 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=userNever 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:
- Open kody.codes/account/mcp-servers and add a server
named
granolawith URLhttps://mcp.granola.ai/mcp(no bearer token). Or ask your agent to run./mcp/connect({ confirm: true }), which does the same thing. - Kody returns an authorization link. Open it, sign in to Granola with the account whose notes you want, and approve access.
- Check:
./mcp/tools({})should showconnected: trueand 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.
| 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 |
./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: truealways 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 forpublic-api.granola.ai. Redirects are not followed. - Webhook signing secrets are shown by Granola once.
./webhooks/createrefuses to create an endpoint unless you choose where the secret goes (saveSigningSecretAsstores 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 — orrevealSigningSecret: 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;429responses are retried with backoff../notes/searchpaces 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 raisingmaxNotes. ./notes/searchonly sees notes the key can read, scans the newest notes first, and reportsscanned,capHitandnextCursorso a partial scan is never mistaken for "no matches". Notes that fail to load are listed infetchErrors(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/getfalls back to the paged transcript endpoint on413 TRANSCRIPT_TOO_LARGE. - Transcript speakers: Granola returns
Me/Themattribution and sometimes a name. Names can carry meeting-app badges ("External unfamiliar", "Unverified");transcript_textstrips them, rawitemskeep Granola's value. - There is no sandbox; Granola suggests testing on a small dedicated folder.
License
MIT — see LICENSE.