← Public packages
@charlesmatte/granola
Search and read Granola meeting notes, summaries and transcripts from any agent; manage webhooks and legal holds safely.
src/lib/client.ts
198 lines · 9.6 KB · TypeScriptimport { GRANOLA_HOST, GRANOLA_ORIGIN, type Account } from './account.ts'
import { DOCS, hostApprovalUrl, secretSetupUrl } from './urls.ts'
export type Query = Record<string, string | number | boolean | undefined | null>
export type GranolaResponse<T = unknown> = { ok: boolean; status: number; url: string; data: T | null; errorCode: string | null }
export class GranolaRequestError extends Error {
readonly status: number
readonly url: string
readonly code: string | null
readonly data: unknown
constructor(message: string, status: number, url: string, code: string | null, data: unknown) {
super(message)
this.name = 'GranolaRequestError'
this.status = status
this.url = url
this.code = code
this.data = data
}
}
/** Build `https://public-api.granola.ai<path>?query`. Only API-relative `/v1/...` paths are accepted. */
export function buildUrl(path: string, query?: Query): string {
if (!/^\/v1\//.test(path)) throw new Error(`Granola helpers only call ${GRANOLA_ORIGIN}/v1/... (got ${path}).`)
const params = new URLSearchParams()
for (const [key, value] of Object.entries(query ?? {})) {
if (value === undefined || value === null || value === '') continue
params.append(key, String(value))
}
const qs = params.toString()
return GRANOLA_ORIGIN + path + (qs ? `?${qs}` : '')
}
/** Error code + message from a Granola error body (shape varies: `{error:{code,message}}`, `{code,message}`, …). */
export function errorDetail(data: unknown): { code: string | null; message: string } {
if (data && typeof data === 'object') {
const d = data as Record<string, any>
const e = d.error && typeof d.error === 'object' ? d.error : d
const code = typeof e.code === 'string' ? e.code : typeof d.code === 'string' ? d.code : null
const parts = [e.message, typeof d.error === 'string' ? d.error : undefined, d.detail, d.title]
.filter((p) => typeof p === 'string' && p.trim())
.map(String)
if (e.details !== undefined) parts.push(`details: ${JSON.stringify(e.details).slice(0, 800)}`)
return { code, message: parts.join('; ').slice(0, 1500) }
}
if (typeof data === 'string' && !/<html/i.test(data)) return { code: null, message: data.slice(0, 500) }
return { code: null, message: '' }
}
/** Granola key types (Settings → Connectors): which endpoints each can use. */
export const KEY_TYPES_HINT =
'Granola has separate key types: Personal API keys (your notes; Settings → Connectors → API keys), Workspace API keys (admins; workspace-visible notes and API-enabled spaces), ' +
'Audit API keys (admins, Enterprise; GET /v1/audit only) and Legal hold API keys (Enterprise, enabled by Granola; /v1/legal-holds only).'
export function statusHint(status: number, a: Account, code: string | null, path = ''): string {
const area = path.startsWith('/v1/audit') ? 'audit' : path.startsWith('/v1/legal-holds') ? 'legalHolds' : path.startsWith('/v1/webhook-endpoints') ? 'webhooks' : 'notes'
if ((status === 403 || status === 404) && area === 'audit') {
return (
`This key cannot read the audit log. GET /v1/audit needs an Audit API key (Enterprise; a workspace admin creates it in Settings → Connectors → Audit API keys, ${DOCS.audit}). ` +
`Personal and workspace API keys get ${status} here. Save the audit key under its own account name, e.g. granola-<account>-audit.`
)
}
if ((status === 403 || status === 404) && area === 'legalHolds' && !/^\/v1\/legal-holds\/lgh_/.test(path)) {
return (
'This key cannot use legal holds. They need a Legal hold API key (Enterprise, enabled by Granola; a workspace admin creates it in Settings → Connectors → API → Legal hold API keys, ' +
`${DOCS.legalHolds}). Save it under its own account name, e.g. granola-<account>-legal.`
)
}
switch (status) {
case 401:
return (
`The API key in Kody secret "${a.secretName}" is invalid, revoked or expired (personal keys can expire). ` +
`Create a new key in the Granola desktop app (Settings → Connectors → API keys, ${DOCS.apiKeys}) and save it at ${secretSetupUrl(a.secretName)}.`
)
case 403:
return (
'The key is not allowed to do this. The Granola API needs a Business or Enterprise plan; on Enterprise an admin chooses which ' +
'scopes members may use; webhook and audit access depend on the key type and role; legal holds need a separate "Legal hold API key" (Enterprise, enabled by Granola).'
)
case 404:
return (
(area === 'webhooks'
? 'Not found, or this key cannot manage webhook endpoints (a personal key sees only the endpoints it created; audit and legal-hold keys see none).'
: area === 'legalHolds'
? 'No such legal hold or custodian for this key. If this secret is not a Legal hold API key, every legal-hold endpoint returns 404 (Enterprise, enabled by Granola; https://docs.granola.ai/legal-holds).'
: 'Not found, or not visible to this key. Notes appear only after their AI summary and transcript are generated, and only if they match the key\'s access scopes ' +
'(personal notes / public notes / API-enabled spaces). If this secret holds an Audit or Legal hold API key, it cannot read notes or folders at all.')
)
case 409:
return 'Conflict (for legal holds: a live hold already has this name, or the hold is released).'
case 413:
return code === 'TRANSCRIPT_TOO_LARGE' || !code
? 'Transcript too large to return inline; fetch it in pages with ./notes/transcript.'
: 'Payload too large.'
case 429:
return 'Rate limited (Granola allows bursts of 25 requests, then 5 requests/second). Wait and retry.'
default:
return status >= 300 && status < 400 ? 'Unexpected redirect (not followed).' : ''
}
}
function wrapFetchError(a: Account, url: string, error: unknown): Error {
const message = error instanceof Error ? error.message : String(error)
if (/host approval|not approved|allowed host|not allowed/i.test(message)) {
return new Error(
`Granola account "${a.account}": Kody has not approved host ${GRANOLA_HOST} for secret "${a.secretName}". ` +
`The Kody account owner must open ${hostApprovalUrl(a.secretName)} and click Allow, then retry. (${message})`,
)
}
if (/secret/i.test(message) && /(not found|missing|unknown|does not exist)/i.test(message)) {
return new Error(
`Granola account "${a.account}": Kody secret "${a.secretName}" does not exist. Create a Granola API key ` +
`(Settings → Connectors → API keys, ${DOCS.apiKeys}) and save it at ${secretSetupUrl(a.secretName)} — never paste keys into chat. (${message})`,
)
}
return new Error(`Granola request to ${url} failed for account "${a.account}": ${message}`)
}
export type RequestOptions = {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'
path: string
query?: Query
json?: unknown
/** Return non-2xx responses instead of throwing (default: throw). */
throwOnError?: boolean
/** Retries on 429 / 503 (default 3; writes default 0 except 429). */
retries?: number
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
/** Seconds from Retry-After, else exponential backoff starting at 1s; capped at 10s. */
export function retryDelayMs(retryAfter: string | null, attempt: number): number {
const seconds = retryAfter ? Number(retryAfter) : Number.NaN
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, 10_000)
return Math.min(1000 * 2 ** attempt, 10_000)
}
/** Injected by tests/harness; production uses the global fetch (Kody swaps the secret placeholder). */
export const transport: { fetch: typeof fetch } = { fetch: (input, init) => fetch(input, init) }
/**
* One authenticated call to the Granola public API. The key is sent as
* `Authorization: Bearer {{secret:<secretName>|scope=user}}`; Kody substitutes the value only for
* an approved host, so the key never enters this isolate. Redirects are never followed.
*/
export async function granolaRequest<T = unknown>(a: Account, options: RequestOptions): Promise<GranolaResponse<T>> {
const method = options.method ?? 'GET'
const url = buildUrl(options.path, options.query)
const headers = new Headers()
headers.set('Accept', 'application/json')
headers.set('User-Agent', 'kody-granola')
headers.set('Authorization', `Bearer {{secret:${a.secretName}|scope=user}}`)
let body: string | undefined
if (options.json !== undefined) {
headers.set('Content-Type', 'application/json')
body = JSON.stringify(options.json)
}
const maxRetries = options.retries ?? 3
for (let attempt = 0; ; attempt++) {
let response: Response
try {
response = await transport.fetch(url, { method, headers, body, redirect: 'manual' })
} catch (error) {
throw wrapFetchError(a, url, error)
}
// 429 means "not processed", so retrying is safe even for writes.
const retriable = response.status === 429 || (response.status === 503 && method === 'GET')
if (retriable && attempt < maxRetries) {
await sleep(retryDelayMs(response.headers.get('retry-after'), attempt))
continue
}
const text = await response.text()
let data: unknown = null
if (text) {
try {
data = JSON.parse(text)
} catch {
data = text
}
}
const ok = response.status >= 200 && response.status < 300
const { code, message } = ok ? { code: null, message: '' } : errorDetail(data)
if (!ok && options.throwOnError !== false) {
const hint = statusHint(response.status, a, code, new URL(url).pathname)
throw new GranolaRequestError(
`Granola ${response.status}${code ? ` ${code}` : ''} on ${method} ${url.replace(GRANOLA_ORIGIN, '')} (account "${a.account}")` +
`${message ? `: ${message}` : ''}.${hint ? ` ${hint}` : ''}`,
response.status,
url,
code,
data,
)
}
return { ok, status: response.status, url, data: data as T | null, errorCode: code }
}
}