← Public packages
@charlesmatte/granola
Search and read Granola meeting notes, summaries and transcripts from any agent; manage webhooks and legal holds safely.
src/notes/list.ts
71 lines · 3.3 KB · TypeScriptimport { listEndpoint, pageSpec, parseWithAccount } from '../lib/api.ts'
import { DATE_PATTERN, FOLDER_ID_PATTERN } from '../lib/validate.ts'
type NoteSummary = { id: string; title: string | null; owner?: { name?: string | null; email?: string }; created_at: string; updated_at: string }
/**
* List Granola meeting notes the API key can see (newest first), following cursors until `limit`.
* Wraps `GET /v1/notes`. Only notes with a generated AI summary and transcript are returned.
* Use `updatedAfter` for incremental sync and `folderId` (from ./folders/list) to scope to a
* folder and its subfolders. `titleContains` / `ownerEmail` filter the fetched page(s) locally
* (the API has no text search), so combine them with a date window for large workspaces.
*
* @param input.account - Account name (Kody secret `granola-<account>` holds the API key). Required.
* @param input.secretName - Use this Kody secret instead of `granola-<account>`.
* @param input.createdAfter - `YYYY-MM-DD` or ISO date-time. @param input.createdBefore - same.
* @param input.updatedAfter - same; notes edited after this time.
* @param input.folderId - `fol_…` id; includes child folders.
* @param input.titleContains - Case-insensitive local filter on the title.
* @param input.ownerEmail - Case-insensitive local filter on the owner's email.
* @param input.limit - Max notes to fetch (1-5000, default 30). @param input.paginate - Follow cursors (default true).
* @param input.cursor - Continue from a previous result's `cursor`.
* @returns `{ account, count, notes: [{ id, title, owner, created_at, updated_at }], hasMore, cursor, pages, filteredOut }`
*
* @example
* import listNotes from 'kody:@charlesmatte/granola/notes/list'
*
* const recent = await listNotes({ account: 'work', createdAfter: '2026-09-01', limit: 50 })
*/
export default async function listGranolaNotes(input: unknown) {
const p = parseWithAccount<{
createdAfter?: string; createdBefore?: string; updatedAfter?: string; folderId?: string
titleContains?: string; ownerEmail?: string; limit?: number; cursor?: string; paginate?: boolean
}>(
input,
{
createdAfter: { type: 'string', pattern: DATE_PATTERN },
createdBefore: { type: 'string', pattern: DATE_PATTERN },
updatedAfter: { type: 'string', pattern: DATE_PATTERN },
folderId: { type: 'string', pattern: FOLDER_ID_PATTERN },
titleContains: { type: 'string', maxLength: 200 },
ownerEmail: { type: 'string', maxLength: 320 },
...pageSpec(5000),
},
'notes/list',
)
const r = await listEndpoint<NoteSummary>(p.acct, {
path: '/v1/notes',
itemsKey: 'notes',
query: { created_after: p.createdAfter, created_before: p.createdBefore, updated_after: p.updatedAfter, folder_id: p.folderId },
limit: p.limit ?? 30,
maxPageSize: 30,
cursor: p.cursor,
paginate: p.paginate ?? true,
})
const title = p.titleContains?.toLowerCase()
const owner = p.ownerEmail?.toLowerCase()
const notes = r.items.filter(
(n) => (!title || (n.title ?? '').toLowerCase().includes(title)) && (!owner || (n.owner?.email ?? '').toLowerCase() === owner),
)
return {
account: p.acct.account,
count: notes.length,
notes,
fetched: r.count,
filteredOut: r.count - notes.length,
hasMore: r.hasMore,
cursor: r.cursor,
pages: r.pages,
...(r.truncated ? { note: 'limit reached mid-page; continuing from cursor may skip the rest of that page' } : {}),
}
}