From 6aa0f8791b8f3ff9681b4e9e651d3b1201e0943e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 20 Sep 2026 22:15:22 -0700 Subject: [PATCH 1/8] docs(readme): Interchange-style install and examples (CL-8775) Registry add commands for @corbits/granola. Examples match public exports. --- README.md | 165 ++++++++++++------------------------------------------ 1 file changed, 37 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index 1deb250..7b3e7ba 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,19 @@ # @corbits/granola -Granola meeting-notes client and agent tools for Corbits hosts, plus optional -webhook ingress and ingest extensions. The base entry point is a plain REST -client (`getNote` / `listNotes` / `listFolders`) and two grantable tool -definitions; `/ingress` receives Granola webhooks, verifies signatures, and -keeps the Granola-side webhook registration converged; `/ingest` is the -host-agnostic pipeline that turns an acked webhook event into a persisted -transcript and dispatched bucket handler. - -## Three entry points, one dependency direction - -| Entry point | What it is | Depends on | -| --- | --- | --- | -| `@corbits/granola` | Granola API client, note/folder types, agent tool definitions | nothing hub-shaped | -| `@corbits/granola/ingress` | Webhook mount, signature verification, folder-binding store, webhook registration | `@corbits/granola`, `hono` (peer) | -| `@corbits/granola/ingest` | Webhook event → note fetch → bucket dispatch → knowledge capture pipeline | `@corbits/granola` | - -The tools are usable standalone. `/ingress` and `/ingest` each depend on the -tools, never the reverse — enforced structurally, see -[ARCHITECTURE.md](./ARCHITECTURE.md). +Granola meeting-notes client and agent tool definitions for Interchange hosts, plus optional webhook ingress and ingest. `@corbits/granola` is a REST client (`getNote` / `listNotes` / `listFolders`) and two grantable tool definitions. `@corbits/granola/ingress` verifies Granola webhooks and keeps registration converged. `@corbits/granola/ingest` turns an acked event into a persisted transcript and a bucket handler. ## Install -Requires Node.js >= 24. - -```bash -bun add github:corbitsdev/corbits-granola -# or pin a commit: -bun add github:corbitsdev/corbits-granola# +```sh +npm add @corbits/granola +pnpm add @corbits/granola +yarn add @corbits/granola +bun add @corbits/granola ``` -Not on npm yet; consume from git or an `npm pack` tarball. The repository root -*is* the package. `@corbits/granola/ingress` additionally requires `hono` ^4 as -a peer dependency. - -## Configuration - -The package takes explicit options — it never reads `process.env` itself. A -host supplies: +Requires Node >= 24. `@corbits/granola/ingress` also needs `hono` ^4 as a peer. The package never reads `process.env`; the host passes `apiKey`, `baseUrl`, and (for ingress) a public origin and signing secret. -| Option | Meaning | Typical env var (host-defined) | -| --- | --- | --- | -| `apiKey` | Granola API key, sent as `Authorization: Bearer` | `GRANOLA_API_KEY` | -| `baseUrl` | Granola REST base URL; defaults to `https://public-api.granola.ai/v1` | `GRANOLA_API_BASE_URL` | -| `publicUrl` | Public HTTPS origin your host is reachable at; used to compute the webhook delivery URL. `undefined` disables webhook registration/reconciliation | `GRANOLA_PUBLIC_URL` | -| `envSecret` (ingress) | Pre-provisioned webhook signing secret, if you have one; otherwise `ensureGranolaWebhook` provisions one | `GRANOLA_WEBHOOK_SECRET` | -| seed bindings (ingress) | Initial folder → bucket-type → channel bindings, `[{folderId, type, channel}]` with `type` one of `"diligence" | "internal"` | `GRANOLA_BUCKETS` (JSON) | - -## Quickstart: client and tools (no webhook) +## Use ```ts import { @@ -58,20 +24,24 @@ import { const client = createGranolaClient({ apiKey: process.env.GRANOLA_API_KEY! }); -const { notes } = await client.listNotes({ limit: 10 }); -const note = await client.getNote(notes[0].id); +const { folders } = await client.listFolders(); +const folderId = folders[0]?.id; +if (folderId === undefined) throw new Error("no folders"); + +const { notes } = await client.listNotes({ folderId, pageSize: 10 }); +const note = await client.getNote(notes[0]!.id, { includeTranscript: true }); console.log(note.title, transcriptText(note)); -// Grant to any agent like any other Interchange tool — no hub required. -// Tool names: granola_fetch_note, granola_search_notes. -for (const tool of GRANOLA_TOOL_DEFINITIONS) grant(tool); +for (const tool of GRANOLA_TOOL_DEFINITIONS) { + void tool.name; +} ``` -## Quickstart: webhook ingress +Tool names: `granola_fetch_note`, `granola_search_notes`. Handlers on those definitions are placeholders. + +## Full example -Mounting is three steps: build a binding store, converge the Granola-side -webhook registration, then mount the webhook route on a Hono app. This is the -pattern Scout uses in production, simplified: +Ingress mounts `POST /api/granola/webhook`. Ingest is everything after ack. ```ts import { Hono } from "hono"; @@ -82,18 +52,18 @@ import { mountGranolaWebhook, reconcileGranolaWebhookFolders, } from "@corbits/granola/ingress"; +import { createGranolaIngest } from "@corbits/granola/ingest"; const apiKey = process.env.GRANOLA_API_KEY!; const baseUrl = "https://public-api.granola.ai/v1"; -const publicUrl = process.env.GRANOLA_PUBLIC_URL; // https:// origin, or undefined +const publicUrl = process.env.GRANOLA_PUBLIC_URL; +const app = new Hono(); -// 1. Durable folder -> bucket-type -> channel bindings. `port` is your -// persistence adapter (implement GranolaBindingsPort over your own store). const bindingStore = createGranolaBindingStore({ port: myBindingsPort, tenantId, principalId, - seedBindings: [], // or parsed GRANOLA_BUCKETS + seedBindings: [], onChange: (bindings) => publicUrl === undefined ? undefined @@ -105,9 +75,6 @@ const bindingStore = createGranolaBindingStore({ }), }); -// 2. Register (or verify) the webhook endpoint with Granola. Returns the -// signing secret; undefined means registration could not happen (e.g. no -// publicUrl and no envSecret) — skip mounting in that case. const secret = await ensureGranolaWebhook({ apiKey, baseUrl, @@ -115,98 +82,40 @@ const secret = await ensureGranolaWebhook({ bindingStore, envSecret: process.env.GRANOLA_WEBHOOK_SECRET, }); -if (secret === undefined) return; - -// 3. Mount. POSTs land at /api/granola/webhook; signatures are verified -// before your handler runs, and events are acked before dispatch. -const app = new Hono(); -mountGranolaWebhook(app, { - secret, - onEvent: async (payload) => { - const note = await createGranolaClient({ apiKey, baseUrl }).getNote(payload.noteId); - // route by bindingStore lookup on the note's folder… - }, -}); -``` - -The webhook route is `POST /api/granola/webhook` on whatever app you pass in. -A failed `onEvent` after ack is not redelivered by Granola; a later event for -the same note (or a manual re-drop) is the recovery path. - -## Quickstart: ingest pipeline - -`/ingress` mounts the webhook, verifies it, and acks; `/ingest` is the -processing pipeline behind it — everything downstream of that ack: fetch the -note, resolve its bucket, persist the transcript, capture knowledge, then -dispatch to a bucket-type handler. It's chat- and host-agnostic, generic over -`TRef` (whatever your transcript store's `persist` returns) and `TAnchor` -(whatever your `lifecycle.onProcessingStarted` returns) — both threaded -through unexamined. - -```ts -import { createGranolaClient } from "@corbits/granola"; -import { mountGranolaWebhook } from "@corbits/granola/ingress"; -import { createGranolaIngest } from "@corbits/granola/ingest"; +if (secret === undefined) throw new Error("granola webhook not registered"); const ingest = createGranolaIngest({ client: createGranolaClient({ apiKey, baseUrl }), bindingStore, - transcripts: myTranscriptStore, // { hasTranscript, persist } -> TRef + transcripts: myTranscriptStore, captureKnowledge: myKnowledgeCapture, - lifecycle: myLifecycle, // onProcessingStarted, onTranscriptReady, ... -> TAnchor + lifecycle: myLifecycle, handlers: { diligence: myDiligenceHandler, internal: myInternalHandler }, }); mountGranolaWebhook(app, { secret, onEvent: ingest }); - -// A host's "reprocess" affordance re-enters bypassing the already-processed gate. await ingest.reprocess(noteId); ``` -## API surface - -`@corbits/granola` - -- `createGranolaClient(options)` → `{ getNote, listNotes, listFolders }` -- `transcriptText(note)` / `speakerLabel(speaker)` — transcript helpers -- `GranolaNote`, `GranolaBucket`, `GranolaBucketType`, `GranolaBucketsArray` — validated data shapes (arktype) -- `fetchNoteTool`, `searchNotesTool`, `GRANOLA_TOOL_DEFINITIONS` — agent tool definitions -- `GranolaApiError` — thrown on non-2xx API responses - -`@corbits/granola/ingress` - -- `mountGranolaWebhook(app, {secret, onEvent})` — mounts `POST /api/granola/webhook` -- `ensureGranolaWebhook(options)` — registers/verifies the webhook with Granola, returns the signing secret -- `reconcileGranolaWebhookFolders(options)` — keeps the registration's `folder_ids` matching your bindings -- `createGranolaBindingStore(options)` — durable folder bindings over a host-supplied `GranolaBindingsPort` -- `verifyGranolaSignature` / `signGranolaPayload` / `parseGranolaPayload` — signature primitives, if you mount by hand - -`@corbits/granola/ingest` - -- `createGranolaIngest(options)` → `GranolaIngest` — the webhook `onEvent` handler, plus `.reprocess(noteId)` / `.reprocessPinned(noteId, companies)` re-entry -- `GranolaIngestHandlers`, `GranolaBucketHandler`, `GranolaBucketHandlerContext`, `GranolaThreadAnchor` — the per-bucket-type handler contract -- `GranolaIngestLifecycle` — the human-visible-event hooks a host implements to render its own copy -- `GranolaTranscriptStore`, `GranolaKnowledgeCapture` — the persistence and enrichment ports a host supplies - -## Used in production +## How it works -Scout mounts this package end to end — client construction, binding store, -webhook registration, and event dispatch — in -`packages/scout/src/granola/mount.ts` and reads its env in -`packages/scout/src/granola/config.ts` of the Scout repository. That wiring is -the reference consumer for everything above. +Three entry points, one dependency direction: tools never import ingress or ingest. `/ingress` verifies signatures, acks, and converges Granola-side `folder_ids`. `/ingest` is generic over the host's transcript ref and lifecycle anchor. A failed `onEvent` after ack is not redelivered; a later event or `ingest.reprocess(noteId)` is the recovery path. -## Working on it +## Contributing ```sh bun install bun run typecheck bun run test +bun run test:tools +bun run test:ingress +bun run test:ingest +bun run check-deps bun run build ``` -See [CONTRIBUTING.md](./CONTRIBUTING.md) and [ARCHITECTURE.md](./ARCHITECTURE.md). +`src/tools/` must not import `src/ingress/` — `bun run check-deps` enforces that. See CONTRIBUTING.md and ARCHITECTURE.md. ## License -LGPL-2.1-only. See [LICENSE](./LICENSE). +LGPL-2.1-only. From 000075dc5a4d8f0de6feae8679b1ae6b7d4e5892 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 20 Sep 2026 22:43:44 -0700 Subject: [PATCH 2/8] docs: align README headings with Interchange (CL-8775) Use Quickstart / How it works / Development / License instead of Install / Use / Full example / Contributing. --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7b3e7ba..ed1e5ae 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ Granola meeting-notes client and agent tool definitions for Interchange hosts, plus optional webhook ingress and ingest. `@corbits/granola` is a REST client (`getNote` / `listNotes` / `listFolders`) and two grantable tool definitions. `@corbits/granola/ingress` verifies Granola webhooks and keeps registration converged. `@corbits/granola/ingest` turns an acked event into a persisted transcript and a bucket handler. -## Install +## Runtime support + +Node >= 24 consumes built `dist/`. Bun loads TypeScript source via the `bun` export condition. `@corbits/granola/ingress` also needs `hono` ^4 as a peer. The package never reads `process.env`; the host passes `apiKey`, `baseUrl`, and (for ingress) a public origin and signing secret. + +## Quickstart ```sh npm add @corbits/granola @@ -11,10 +15,6 @@ yarn add @corbits/granola bun add @corbits/granola ``` -Requires Node >= 24. `@corbits/granola/ingress` also needs `hono` ^4 as a peer. The package never reads `process.env`; the host passes `apiKey`, `baseUrl`, and (for ingress) a public origin and signing secret. - -## Use - ```ts import { createGranolaClient, @@ -39,8 +39,6 @@ for (const tool of GRANOLA_TOOL_DEFINITIONS) { Tool names: `granola_fetch_note`, `granola_search_notes`. Handlers on those definitions are placeholders. -## Full example - Ingress mounts `POST /api/granola/webhook`. Ingest is everything after ack. ```ts @@ -101,9 +99,11 @@ await ingest.reprocess(noteId); Three entry points, one dependency direction: tools never import ingress or ingest. `/ingress` verifies signatures, acks, and converges Granola-side `folder_ids`. `/ingest` is generic over the host's transcript ref and lifecycle anchor. A failed `onEvent` after ack is not redelivered; a later event or `ingest.reprocess(noteId)` is the recovery path. -## Contributing +## Development ```sh +git clone https://github.com/corbitsdev/granola-tools.git +cd granola-tools bun install bun run typecheck bun run test From af17ee1df40159e57585bf94487ee34f3687acce Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 20 Sep 2026 22:44:19 -0700 Subject: [PATCH 3/8] docs(readme): Interchange H2 names (CL-8775) Swap package README headings to Interchange names: Runtime support, Quickstart, How it works, Development, License. Keep registry add, not github:. --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac5fd2c..dc09250 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ A small, deliberately boring codebase: strict TypeScript, no magic. -Setup and the commands are in the [README](./README.md#working-on-it). `bun run +Setup and the commands are in the [README](./README.md#development). `bun run typecheck` must be clean — it is its own CI step, and `any` is not a way past it. ## Dependency rule From eb958cd3774944329e11a56447f45b5f9bd39664 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 19:06:32 -0700 Subject: [PATCH 4/8] docs: product-voice README (CL-8775) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ed1e5ae..8835a55 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Granola meeting-notes client and agent tool definitions for Interchange hosts, p ## Runtime support -Node >= 24 consumes built `dist/`. Bun loads TypeScript source via the `bun` export condition. `@corbits/granola/ingress` also needs `hono` ^4 as a peer. The package never reads `process.env`; the host passes `apiKey`, `baseUrl`, and (for ingress) a public origin and signing secret. +Node >= 24 consumes built `dist/`. Bun loads TypeScript source via the `bun` export condition. `@corbits/granola/ingress` also needs `hono` ^4 as a peer. The host supplies `apiKey`, `baseUrl`, and (for ingress) a public origin and signing secret. ## Quickstart @@ -97,7 +97,7 @@ await ingest.reprocess(noteId); ## How it works -Three entry points, one dependency direction: tools never import ingress or ingest. `/ingress` verifies signatures, acks, and converges Granola-side `folder_ids`. `/ingest` is generic over the host's transcript ref and lifecycle anchor. A failed `onEvent` after ack is not redelivered; a later event or `ingest.reprocess(noteId)` is the recovery path. +Three entry points, one dependency direction: ingress and ingest build on tools. `/ingress` verifies signatures, acks, and converges Granola-side `folder_ids`. `/ingest` is generic over the host's transcript ref and lifecycle anchor. A failed `onEvent` after ack is not redelivered; a later event or `ingest.reprocess(noteId)` is the recovery path. ## Development From f6cce41638f41b633a4c59f94f7afadb4a5a6a35 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 20:06:46 -0700 Subject: [PATCH 5/8] docs(readme): accurate Quickstart against src exports (CL-8775) --- README.md | 100 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 79 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 8835a55..34432b5 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,10 @@ Granola meeting-notes client and agent tool definitions for Interchange hosts, plus optional webhook ingress and ingest. `@corbits/granola` is a REST client (`getNote` / `listNotes` / `listFolders`) and two grantable tool definitions. `@corbits/granola/ingress` verifies Granola webhooks and keeps registration converged. `@corbits/granola/ingest` turns an acked event into a persisted transcript and a bucket handler. -## Runtime support +## Quickstart Node >= 24 consumes built `dist/`. Bun loads TypeScript source via the `bun` export condition. `@corbits/granola/ingress` also needs `hono` ^4 as a peer. The host supplies `apiKey`, `baseUrl`, and (for ingress) a public origin and signing secret. -## Quickstart - ```sh npm add @corbits/granola pnpm add @corbits/granola @@ -33,7 +31,7 @@ const note = await client.getNote(notes[0]!.id, { includeTranscript: true }); console.log(note.title, transcriptText(note)); for (const tool of GRANOLA_TOOL_DEFINITIONS) { - void tool.name; + console.log(tool.name, "-", tool.description); } ``` @@ -43,34 +41,57 @@ Ingress mounts `POST /api/granola/webhook`. Ingest is everything after ack. ```ts import { Hono } from "hono"; -import { createGranolaClient } from "@corbits/granola"; +import { createGranolaClient, type GranolaBucket } from "@corbits/granola"; import { createGranolaBindingStore, ensureGranolaWebhook, mountGranolaWebhook, reconcileGranolaWebhookFolders, + type GranolaBindingsPort, } from "@corbits/granola/ingress"; -import { createGranolaIngest } from "@corbits/granola/ingest"; +import { + createGranolaIngest, + type GranolaIngestHandlers, + type GranolaIngestLifecycle, + type GranolaKnowledgeCapture, + type GranolaTranscriptStore, +} from "@corbits/granola/ingest"; const apiKey = process.env.GRANOLA_API_KEY!; const baseUrl = "https://public-api.granola.ai/v1"; const publicUrl = process.env.GRANOLA_PUBLIC_URL; const app = new Hono(); +// Host-owned: back this port with the host's own durable storage (shown in-memory). +const stored = new Map(); +const port: GranolaBindingsPort = { + async load({ tenantId }) { + return stored.get(tenantId); + }, + async save({ tenantId, bindings }) { + const version = (stored.get(tenantId)?.version ?? 0) + 1; + stored.set(tenantId, { bindings, version }); + }, +}; + +// Host-owned: the host's own tenant and operator. +const tenantId = "tenant-1"; +const principalId = "operator-1"; + const bindingStore = createGranolaBindingStore({ - port: myBindingsPort, + port, tenantId, principalId, seedBindings: [], - onChange: (bindings) => - publicUrl === undefined - ? undefined - : reconcileGranolaWebhookFolders({ - apiKey, - baseUrl, - publicUrl, - folderIds: bindings.map((b) => b.folderId), - }), + onChange: (bindings) => { + if (publicUrl === undefined) return undefined; + return reconcileGranolaWebhookFolders({ + apiKey, + baseUrl, + publicUrl, + folderIds: bindings.map((b) => b.folderId), + }); + }, }); const secret = await ensureGranolaWebhook({ @@ -82,17 +103,54 @@ const secret = await ensureGranolaWebhook({ }); if (secret === undefined) throw new Error("granola webhook not registered"); +// Host-owned: persist transcripts in the host's own store; persist returns the host's ref. +const transcripts: GranolaTranscriptStore = { + async hasTranscript() { + return false; + }, + async persist({ granolaNoteId }) { + return granolaNoteId; + }, +}; + +// Host-owned: enrich the persisted artifact (embeddings, knowledge graph, ...). +const captureKnowledge: GranolaKnowledgeCapture = async () => {}; + +// Host-owned: render every human-visible event in the host's own chat. +const lifecycle: GranolaIngestLifecycle = { + async onDuplicateEvent() {}, + async onFetchFailed() {}, + async onNoteNotGenerated() {}, + async onAlreadyProcessed() {}, + async onProcessingStarted() { + return { channel: "granola", ts: "ack" }; + }, + async onNoteNotReady() {}, + async onTranscriptReady() {}, + async onPersistFailed() {}, + async onHandlerDispatching() {}, + async onHandlerFailed() {}, +}; + +// Host-owned: one behavior per bucket type; a type with no handler logs and no-ops. +const handlers: GranolaIngestHandlers = { + diligence: async () => {}, + internal: async () => {}, +}; + const ingest = createGranolaIngest({ client: createGranolaClient({ apiKey, baseUrl }), bindingStore, - transcripts: myTranscriptStore, - captureKnowledge: myKnowledgeCapture, - lifecycle: myLifecycle, - handlers: { diligence: myDiligenceHandler, internal: myInternalHandler }, + transcripts, + captureKnowledge, + lifecycle, + handlers, }); mountGranolaWebhook(app, { secret, onEvent: ingest }); -await ingest.reprocess(noteId); + +// Example: re-enter the pipeline for one known note, bypassing the already-processed gate. +await ingest.reprocess("note-123"); ``` ## How it works From 949ef5b9330052700cf32e64a50408d78b183107 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 20:23:37 -0700 Subject: [PATCH 6/8] docs(readme): runnable-only Quickstart, one program (CL-8775) --- README.md | 120 ++---------------------------------------------------- 1 file changed, 4 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index 34432b5..8054999 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,12 @@ Granola meeting-notes client and agent tool definitions for Interchange hosts, plus optional webhook ingress and ingest. `@corbits/granola` is a REST client (`getNote` / `listNotes` / `listFolders`) and two grantable tool definitions. `@corbits/granola/ingress` verifies Granola webhooks and keeps registration converged. `@corbits/granola/ingest` turns an acked event into a persisted transcript and a bucket handler. -## Quickstart +## Runtime support Node >= 24 consumes built `dist/`. Bun loads TypeScript source via the `bun` export condition. `@corbits/granola/ingress` also needs `hono` ^4 as a peer. The host supplies `apiKey`, `baseUrl`, and (for ingress) a public origin and signing secret. +## Quickstart + ```sh npm add @corbits/granola pnpm add @corbits/granola @@ -37,121 +39,7 @@ for (const tool of GRANOLA_TOOL_DEFINITIONS) { Tool names: `granola_fetch_note`, `granola_search_notes`. Handlers on those definitions are placeholders. -Ingress mounts `POST /api/granola/webhook`. Ingest is everything after ack. - -```ts -import { Hono } from "hono"; -import { createGranolaClient, type GranolaBucket } from "@corbits/granola"; -import { - createGranolaBindingStore, - ensureGranolaWebhook, - mountGranolaWebhook, - reconcileGranolaWebhookFolders, - type GranolaBindingsPort, -} from "@corbits/granola/ingress"; -import { - createGranolaIngest, - type GranolaIngestHandlers, - type GranolaIngestLifecycle, - type GranolaKnowledgeCapture, - type GranolaTranscriptStore, -} from "@corbits/granola/ingest"; - -const apiKey = process.env.GRANOLA_API_KEY!; -const baseUrl = "https://public-api.granola.ai/v1"; -const publicUrl = process.env.GRANOLA_PUBLIC_URL; -const app = new Hono(); - -// Host-owned: back this port with the host's own durable storage (shown in-memory). -const stored = new Map(); -const port: GranolaBindingsPort = { - async load({ tenantId }) { - return stored.get(tenantId); - }, - async save({ tenantId, bindings }) { - const version = (stored.get(tenantId)?.version ?? 0) + 1; - stored.set(tenantId, { bindings, version }); - }, -}; - -// Host-owned: the host's own tenant and operator. -const tenantId = "tenant-1"; -const principalId = "operator-1"; - -const bindingStore = createGranolaBindingStore({ - port, - tenantId, - principalId, - seedBindings: [], - onChange: (bindings) => { - if (publicUrl === undefined) return undefined; - return reconcileGranolaWebhookFolders({ - apiKey, - baseUrl, - publicUrl, - folderIds: bindings.map((b) => b.folderId), - }); - }, -}); - -const secret = await ensureGranolaWebhook({ - apiKey, - baseUrl, - publicUrl, - bindingStore, - envSecret: process.env.GRANOLA_WEBHOOK_SECRET, -}); -if (secret === undefined) throw new Error("granola webhook not registered"); - -// Host-owned: persist transcripts in the host's own store; persist returns the host's ref. -const transcripts: GranolaTranscriptStore = { - async hasTranscript() { - return false; - }, - async persist({ granolaNoteId }) { - return granolaNoteId; - }, -}; - -// Host-owned: enrich the persisted artifact (embeddings, knowledge graph, ...). -const captureKnowledge: GranolaKnowledgeCapture = async () => {}; - -// Host-owned: render every human-visible event in the host's own chat. -const lifecycle: GranolaIngestLifecycle = { - async onDuplicateEvent() {}, - async onFetchFailed() {}, - async onNoteNotGenerated() {}, - async onAlreadyProcessed() {}, - async onProcessingStarted() { - return { channel: "granola", ts: "ack" }; - }, - async onNoteNotReady() {}, - async onTranscriptReady() {}, - async onPersistFailed() {}, - async onHandlerDispatching() {}, - async onHandlerFailed() {}, -}; - -// Host-owned: one behavior per bucket type; a type with no handler logs and no-ops. -const handlers: GranolaIngestHandlers = { - diligence: async () => {}, - internal: async () => {}, -}; - -const ingest = createGranolaIngest({ - client: createGranolaClient({ apiKey, baseUrl }), - bindingStore, - transcripts, - captureKnowledge, - lifecycle, - handlers, -}); - -mountGranolaWebhook(app, { secret, onEvent: ingest }); - -// Example: re-enter the pipeline for one known note, bypassing the already-processed gate. -await ingest.reprocess("note-123"); -``` +Ingress mounts `POST /api/granola/webhook` via `mountGranolaWebhook` (backed by `createGranolaBindingStore`, `ensureGranolaWebhook`, and `reconcileGranolaWebhookFolders` from `@corbits/granola/ingress`); ingest is everything after ack via `createGranolaIngest` from `@corbits/granola/ingest`, with the host supplying the transcript store, knowledge capture, lifecycle, and bucket handlers. ## How it works From 73e6a8a064a6dcc0fe49657b0db5bb0b4dd37439 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 21:08:20 -0700 Subject: [PATCH 7/8] docs(readme): Quickstart shows real host usage (CL-8775) --- README.md | 141 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 128 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 8054999..ac67234 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,19 @@ # @corbits/granola -Granola meeting-notes client and agent tool definitions for Interchange hosts, plus optional webhook ingress and ingest. `@corbits/granola` is a REST client (`getNote` / `listNotes` / `listFolders`) and two grantable tool definitions. `@corbits/granola/ingress` verifies Granola webhooks and keeps registration converged. `@corbits/granola/ingest` turns an acked event into a persisted transcript and a bucket handler. +Granola meeting-notes tools for Interchange hosts: a REST client and agent +tool definitions at the package root, a webhook receiver at `/ingress`, and a +host-agnostic processing pipeline at `/ingest`. `/ingress` and `/ingest` +depend on the root export; the root export depends on neither — see +[ARCHITECTURE.md](./ARCHITECTURE.md). ## Runtime support -Node >= 24 consumes built `dist/`. Bun loads TypeScript source via the `bun` export condition. `@corbits/granola/ingress` also needs `hono` ^4 as a peer. The host supplies `apiKey`, `baseUrl`, and (for ingress) a public origin and signing secret. +Node >= 24 consumes built `dist/`. Bun loads TypeScript source directly via +the `bun` export condition. `@corbits/granola/ingress` additionally needs +`hono` ^4 as a peer. Nothing in the package reads `process.env` itself — every +credential, base URL, and store is a constructor argument the host supplies. -## Quickstart +## Install ```sh npm add @corbits/granola @@ -15,6 +22,108 @@ yarn add @corbits/granola bun add @corbits/granola ``` +Not on npm yet — install from git (`bun add github:corbitsdev/corbits-granola`, +ideally pinned to a commit) until it is. + +## Quickstart + +Scout mounts Granola end to end this way, in +`packages/scout/src/granola/mount.ts`: build the folder-binding store, +converge the Granola-side webhook registration, wire the ingest pipeline, and +mount the webhook route on the host's Hono app. The host supplies persistence +(`bindingsPort`, `transcripts`), enrichment (`captureKnowledge`), and how to +render each pipeline event (`lifecycle`) — this package never touches storage +or chat itself. + +```ts +import type { Hono } from "hono"; +import { createGranolaClient, type GranolaBucket } from "@corbits/granola"; +import { + createGranolaBindingStore, + ensureGranolaWebhook, + mountGranolaWebhook, + reconcileGranolaWebhookFolders, + type GranolaBindingsPort, +} from "@corbits/granola/ingress"; +import { + createGranolaIngest, + type GranolaTranscriptStore, + type GranolaKnowledgeCapture, + type GranolaIngestLifecycle, +} from "@corbits/granola/ingest"; + +export async function installGranolaIngestion( + app: Hono, + config: { + apiKey: string; + baseUrl: string; + publicUrl: string | undefined; // undefined disables webhook registration + envSecret: string | undefined; // GRANOLA_WEBHOOK_SECRET, if already provisioned + tenantId: string; + principalId: string; + bindingsPort: GranolaBindingsPort; // host's own storage + seedBindings: GranolaBucket[]; + transcripts: GranolaTranscriptStore; // host's transcript persistence + captureKnowledge: GranolaKnowledgeCapture; // host's enrichment step + lifecycle: GranolaIngestLifecycle; // host renders each pipeline event + }, +): Promise { + const { apiKey, baseUrl, publicUrl } = config; + + const bindingStore = createGranolaBindingStore({ + port: config.bindingsPort, + tenantId: config.tenantId, + principalId: config.principalId, + seedBindings: config.seedBindings, + onChange: (bindings) => + publicUrl === undefined + ? undefined + : reconcileGranolaWebhookFolders({ + apiKey, + baseUrl, + publicUrl, + folderIds: bindings.map((bucket) => bucket.folderId), + }), + }); + + // Registers (or verifies) the webhook endpoint with Granola and returns + // its signing secret. undefined means nothing to mount with (no publicUrl + // and no envSecret, or reconciliation failed with no fallback secret). + const secret = await ensureGranolaWebhook({ + apiKey, + baseUrl, + publicUrl, + bindingStore, + envSecret: config.envSecret, + }); + if (secret === undefined) return; + + const ingest = createGranolaIngest({ + client: createGranolaClient({ apiKey, baseUrl }), + bindingStore, + transcripts: config.transcripts, + captureKnowledge: config.captureKnowledge, + lifecycle: config.lifecycle, + }); + + // Mounts POST /api/granola/webhook. Signatures are verified and the + // request is acked before `ingest` runs; a failed `ingest` after ack is + // not redelivered by Granola — a later event for the same note (or + // `ingest.reprocess(noteId)`) is the recovery path. + mountGranolaWebhook(app, { secret, onEvent: ingest }); +} +``` + +Per-bucket-type behavior (e.g. what happens once a note is persisted) is +injected through `createGranolaIngest`'s `handlers` option — one +`GranolaBucketHandler` per `GranolaBucketType` — not shown above. + +## Lower-level: client and tool definitions + +The package root has no dependency on `/ingress` or `/ingest` — it can be +imported and its tools granted to any agent standalone, with nothing +hub-shaped attached. + ```ts import { createGranolaClient, @@ -22,7 +131,7 @@ import { GRANOLA_TOOL_DEFINITIONS, } from "@corbits/granola"; -const client = createGranolaClient({ apiKey: process.env.GRANOLA_API_KEY! }); +const client = createGranolaClient({ apiKey: process.env["GRANOLA_API_KEY"]! }); const { folders } = await client.listFolders(); const folderId = folders[0]?.id; @@ -37,13 +146,20 @@ for (const tool of GRANOLA_TOOL_DEFINITIONS) { } ``` -Tool names: `granola_fetch_note`, `granola_search_notes`. Handlers on those definitions are placeholders. - -Ingress mounts `POST /api/granola/webhook` via `mountGranolaWebhook` (backed by `createGranolaBindingStore`, `ensureGranolaWebhook`, and `reconcileGranolaWebhookFolders` from `@corbits/granola/ingress`); ingest is everything after ack via `createGranolaIngest` from `@corbits/granola/ingest`, with the host supplying the transcript store, knowledge capture, lifecycle, and bucket handlers. +`GRANOLA_TOOL_DEFINITIONS` currently holds `granola_fetch_note` and +`granola_search_notes`; both handlers throw `not implemented` today — +grantable shape, no working body yet. ## How it works -Three entry points, one dependency direction: ingress and ingest build on tools. `/ingress` verifies signatures, acks, and converges Granola-side `folder_ids`. `/ingest` is generic over the host's transcript ref and lifecycle anchor. A failed `onEvent` after ack is not redelivered; a later event or `ingest.reprocess(noteId)` is the recovery path. +`src/tools` is the client and tool shapes — no hub, mounting, extension, or +webhook dependency, enforced by `bun run check-deps`. `src/ingress` verifies +and mounts the webhook and keeps the Granola-side registration converged. +`src/ingest` is everything after ack: fetch the note, resolve which bucket +its folder is bound to, persist the transcript, capture knowledge, then +dispatch to that bucket type's handler — generic over the host's transcript +ref and lifecycle anchor types, so it never assumes a specific chat client or +storage schema. ## Development @@ -53,15 +169,14 @@ cd granola-tools bun install bun run typecheck bun run test -bun run test:tools -bun run test:ingress -bun run test:ingest bun run check-deps bun run build ``` -`src/tools/` must not import `src/ingress/` — `bun run check-deps` enforces that. See CONTRIBUTING.md and ARCHITECTURE.md. +`bun run test:tools` / `test:ingress` / `test:ingest` run each face's tests +independently. See [CONTRIBUTING.md](./CONTRIBUTING.md) and +[ARCHITECTURE.md](./ARCHITECTURE.md). ## License -LGPL-2.1-only. +LGPL-2.1-only. See [LICENSE](./LICENSE). From e8825d10cbadd7723d87228746603f8eb6e9b1b7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 21:10:13 -0700 Subject: [PATCH 8/8] docs(readme): drop internal consumer reference (CL-8775) --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index ac67234..3cdca82 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,7 @@ ideally pinned to a commit) until it is. ## Quickstart -Scout mounts Granola end to end this way, in -`packages/scout/src/granola/mount.ts`: build the folder-binding store, +A host mounts Granola end to end this way: build the folder-binding store, converge the Granola-side webhook registration, wire the ingest pipeline, and mount the webhook route on the host's Hono app. The host supplies persistence (`bindingsPort`, `transcripts`), enrichment (`captureKnowledge`), and how to