# @charlesmatte/granola — agent guide Granola meeting notes through the official public API (`https://public-api.granola.ai/v1`). ## Rules 1. Pass `account` on every call (except `.`, `./accounts`, `./mcp/*`). There is no default. If you don't know it, call `./accounts` and use a `ready: true` account, or ask the user which one. 2. Never ask the user to paste a Granola key into chat. Missing key → give them the `setupUrl` from `./accounts({ account })`; host not approved → the `hostApprovalUrl`. 3. Notes are read-only through the API. Reading is always fine for the user's own request. 4. Writes (`webhooks/create|update|delete`, `legal-holds/create|update|release`, `legal-holds/custodians/add|remove`, `mcp/connect`) return a preview unless `confirm: true`. Show the preview; confirm only when the user asked for that exact change. `legal-holds/release` is permanent. 5. `webhooks/create` with `confirm: true` needs `saveSigningSecretAs` or `revealSigningSecret: true`. Never echo a returned signing secret into logs; hand it to the user once. 6. Unknown input keys throw; read the "Accepted shape" in the error and retry. ## Imports ```ts import overview from 'kody:@charlesmatte/granola' import accounts from 'kody:@charlesmatte/granola/accounts' import verify from 'kody:@charlesmatte/granola/verify' import listNotes from 'kody:@charlesmatte/granola/notes/list' import getNote from 'kody:@charlesmatte/granola/notes/get' import transcript from 'kody:@charlesmatte/granola/notes/transcript' import searchNotes from 'kody:@charlesmatte/granola/notes/search' import listFolders from 'kody:@charlesmatte/granola/folders/list' import listAudit from 'kody:@charlesmatte/granola/audit/list' import listWebhooks from 'kody:@charlesmatte/granola/webhooks/list' import createWebhook from 'kody:@charlesmatte/granola/webhooks/create' import updateWebhook from 'kody:@charlesmatte/granola/webhooks/update' import deleteWebhook from 'kody:@charlesmatte/granola/webhooks/delete' import listHolds from 'kody:@charlesmatte/granola/legal-holds/list' import getHold from 'kody:@charlesmatte/granola/legal-holds/get' import createHold from 'kody:@charlesmatte/granola/legal-holds/create' import updateHold from 'kody:@charlesmatte/granola/legal-holds/update' import releaseHold from 'kody:@charlesmatte/granola/legal-holds/release' import listCustodians from 'kody:@charlesmatte/granola/legal-holds/custodians/list' import addCustodians from 'kody:@charlesmatte/granola/legal-holds/custodians/add' import removeCustodian from 'kody:@charlesmatte/granola/legal-holds/custodians/remove' import mcpTools from 'kody:@charlesmatte/granola/mcp/tools' import mcpConnect from 'kody:@charlesmatte/granola/mcp/connect' import askGranola from 'kody:@charlesmatte/granola/mcp/query' import mcpCall from 'kody:@charlesmatte/granola/mcp/call' ``` ## Common flows - **Pick the search kind.** Exact names, codes, phrases or "which meetings mention X" → `searchNotes({ account, query })` (keyword). Questions that need understanding or a summary across meetings → `askGranola({ question })` (semantic, MCP). If one lane is not set up, use the other and say so. - **Keyword search** → `searchNotes({ account, query: 'acme "next steps" -internal', createdAfter })`. Narrow with dates/`folderId` before raising `maxNotes`. If `capHit` is true, say the search was partial (`scanned` notes) and offer to continue with `cursor: nextCursor`. Add `includeTranscripts: true` only for spoken wording. Link results with `web_url`. - **Semantic search** → `askGranola({ question })`. Keep the `citations` links in your answer. If it throws "not connected", give the user the one-time setup from the message (add server `granola` = `https://mcp.granola.ai/mcp` at https://kody.codes/account/mcp-servers, then sign in); only run `mcpConnect({ confirm: true })` yourself if the user asked, and hand them the `authUrl`. - **"What did we discuss with X?"** → `listNotes({ account, createdAfter, titleContains: 'X' })` (title filter is local; widen the date window or drop it if nothing matches) → `getNote({ account, noteId })` → answer from `summary_markdown`; add `includeTranscript: true` only when quotes are needed. - **Quotes / exact wording** → `transcript({ account, noteId })` returns `text` like `[12:04] Them: …`. Speaker names are often unknown: `Me`/`Them` or `Speaker A`. - **By folder** → `listFolders({ account })` (each has `path`) → `listNotes({ account, folderId })`. - **Since last run** → `listNotes({ account, updatedAfter: '' , limit: 1000 })`. - **Other MCP tools** → `mcpTools({})` for status, then `mcpCall({ tool, args })`. ## Return shapes - `notes/list`: `{ account, count, notes: [{ id, title, owner: { name, email }, created_at, updated_at }], fetched, filteredOut, hasMore, cursor, pages }` - `notes/get`: Granola note (`id, title, owner, created_at, updated_at, web_url, calendar_event, attendees, folder_membership, summary_text, summary_markdown, private_notes_text, private_notes_markdown`) + `transcriptSource`, `transcriptItemCount`, `transcript_text` and/or `transcript` - `notes/search`: `{ account, query, terms, excluded, mode, searchedFields, scanned, listed, capHit, nextCursor, coverage, fetchErrors, matchedCount, results: [{ noteId, title, created_at, updated_at, web_url, owner, score, termsMatched, hits: { field: count }, snippets: [{ field, text }] }], resultsTruncated, elapsedMs }` - `mcp/query`: `{ server, question, answer, citations: [{ ref, url }], isError, hint?, raw? }` - `notes/transcript`: `{ account, noteId, count, text?, items?, hasMore, cursor, pages }` - `folders/list`: `{ account, count, folders: [{ id, name, parent_folder_id, path, depth, childCount }], tree? }` - `verify`: `{ account, secretName, keyWorks, keyKind ('personal or workspace API key' | 'audit key' | 'legal-hold key' | 'notes key with audit access' | 'invalid' | 'unknown'), keyKindNote, probes: { notes|folders|webhooks|audit|legalHolds: { status, ok, … } }, summary }` - Write previews: `{ dryRun: true, executed: false, echo, reason, request, current?, next }`; executed writes: `{ dryRun: false, executed: true, echo, … }` ## Errors - `401` → key invalid/revoked/expired; the message includes the Kody secret link. - `403` → plan, admin-controlled scope, or key type does not allow it. - `404` → not found or not visible (notes appear only after summary + transcript exist). On `audit/list` / `legal-holds/*` with a personal or workspace key, 404 means the wrong key type: the audit log needs an Audit API key, legal holds a Legal hold API key (each admin-created, Enterprise). Tell the user; don't retry. `verify` returns `keyKind` + `keyKindNote`. - "Kody has not approved host public-api.granola.ai" → send the user the approval link in the message. - `429` is retried automatically; persistent 429 → slow down. - `notes/search` aborts on `401` (bad key) instead of returning an empty result. - MCP "not connected" → the user must do the one-time sign-in (rule above); don't retry.