Skip to content
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
303 changes: 136 additions & 167 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,211 +1,180 @@
# @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
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).

## Install

Requires Node.js >= 24.

```bash
bun add github:corbitsdev/corbits-granola
# or pin a commit:
bun add github:corbitsdev/corbits-granola#<sha>
```

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.
## Runtime support

## Configuration
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.

The package takes explicit options — it never reads `process.env` itself. A
host supplies:

| 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)

```ts
import {
createGranolaClient,
transcriptText,
GRANOLA_TOOL_DEFINITIONS,
} from "@corbits/granola";

const client = createGranolaClient({ apiKey: process.env.GRANOLA_API_KEY! });

const { notes } = await client.listNotes({ limit: 10 });
const note = await client.getNote(notes[0].id);
console.log(note.title, transcriptText(note));
## Install

// 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);
```sh
npm add @corbits/granola
pnpm add @corbits/granola
yarn add @corbits/granola
bun add @corbits/granola
```

## Quickstart: webhook ingress
Not on npm yet — install from git (`bun add github:corbitsdev/corbits-granola`,
ideally pinned to a commit) until it is.

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:
## Quickstart

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
render each pipeline event (`lifecycle`) — this package never touches storage
or chat itself.

```ts
import { Hono } from "hono";
import { createGranolaClient } from "@corbits/granola";
import type { Hono } from "hono";
import { createGranolaClient, type GranolaBucket } from "@corbits/granola";
import {
createGranolaBindingStore,
ensureGranolaWebhook,
mountGranolaWebhook,
reconcileGranolaWebhookFolders,
type GranolaBindingsPort,
} from "@corbits/granola/ingress";

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

// 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
onChange: (bindings) =>
publicUrl === undefined
? undefined
: reconcileGranolaWebhookFolders({
apiKey,
baseUrl,
publicUrl,
folderIds: bindings.map((b) => b.folderId),
}),
});

// 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,
publicUrl,
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…
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<GranolaBucket>; // 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<void> {
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 });
}
```

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.
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.

## Quickstart: ingest pipeline
## Lower-level: client and tool definitions

`/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.
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 } from "@corbits/granola";
import { mountGranolaWebhook } from "@corbits/granola/ingress";
import { createGranolaIngest } from "@corbits/granola/ingest";

const ingest = createGranolaIngest({
client: createGranolaClient({ apiKey, baseUrl }),
bindingStore,
transcripts: myTranscriptStore, // { hasTranscript, persist } -> TRef
captureKnowledge: myKnowledgeCapture,
lifecycle: myLifecycle, // onProcessingStarted, onTranscriptReady, ... -> TAnchor
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`
import {
createGranolaClient,
transcriptText,
GRANOLA_TOOL_DEFINITIONS,
} from "@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
const client = createGranolaClient({ apiKey: process.env["GRANOLA_API_KEY"]! });

`@corbits/granola/ingress`
const { folders } = await client.listFolders();
const folderId = folders[0]?.id;
if (folderId === undefined) throw new Error("no folders");

- `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
const { notes } = await client.listNotes({ folderId, pageSize: 10 });
const note = await client.getNote(notes[0]!.id, { includeTranscript: true });
console.log(note.title, transcriptText(note));

`@corbits/granola/ingest`
for (const tool of GRANOLA_TOOL_DEFINITIONS) {
console.log(tool.name, "-", tool.description);
}
```

- `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
`GRANOLA_TOOL_DEFINITIONS` currently holds `granola_fetch_note` and
`granola_search_notes`; both handlers throw `not implemented` today —
grantable shape, no working body yet.

## 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.
`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.

## Working on it
## Development

```sh
git clone https://github.com/corbitsdev/granola-tools.git
cd granola-tools
bun install
bun run typecheck
bun run test
bun run check-deps
bun run build
```

See [CONTRIBUTING.md](./CONTRIBUTING.md) and [ARCHITECTURE.md](./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

Expand Down
Loading