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.

src/lib/client.ts

198 lines · 9.6 KB · TypeScript
Raw
import { 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 }
	}
}