Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 174 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,51 +1,199 @@
# @corbits/granola

Granola meeting-notes tools for Corbits agents, plus an optional webhook ingress
extension. Skeleton package — no Granola API calls are implemented yet; this is the
foundation the real client and dispatch logic land into.
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.

## Two entry points, one dependency direction
## Three entry points, one dependency direction

| Entry point | What it is | Depends on |
| --- | --- | --- |
| `@corbits/granola` | The Granola API client and the tools an agent calls (fetch a note, search notes) | nothing hub-shaped |
| `@corbits/granola/ingress` | The extension that receives Granola webhooks, verifies signatures, and dispatches notes to handlers | `@corbits/granola` |

**The tools are usable standalone, without the ingress extension.** `@corbits/granola`
carries zero dependency on any hub, mounting, extension or webhook machinery — import it
and grant its tools to any agent exactly like any other plain Interchange tool, and
nothing hub-shaped comes along for the ride. `@corbits/granola/ingress` is the one
direction of coupling: it depends on the tools to do its job. The tools never depend on
it, and that direction is enforced structurally, not just by convention — see
| `@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).

## Install

```bash
# From git (Bun)
bun add github:corbitsdev/corbits-granola

# npm / pack
npm install @corbits/granola
# or pin a commit:
bun add github:corbitsdev/corbits-granola#<sha>
```

> **Not on npm yet.** Until the first release, consume it from git or an `npm pack`
> tarball. This repository root *is* the package, so git installs resolve cleanly.
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.

## Use
## Configuration

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 { GranolaClient, GRANOLA_TOOL_DEFINITIONS } from "@corbits/granola";
import {
createGranolaClient,
transcriptText,
GRANOLA_TOOL_DEFINITIONS,
} from "@corbits/granola";

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

const client = new GranolaClient({ apiKey: process.env.GRANOLA_API_KEY! });
// Grant GRANOLA_TOOL_DEFINITIONS to any agent, no hub required.
const { notes } = await client.listNotes({ limit: 10 });
const note = await client.getNote(notes[0].id);
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);
```

## Quickstart: webhook ingress

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:

```ts
// Only if you also want the webhook extension.
import { verifyGranolaSignature } from "@corbits/granola/ingress";
import { Hono } from "hono";
import { createGranolaClient } from "@corbits/granola";
import {
createGranolaBindingStore,
ensureGranolaWebhook,
mountGranolaWebhook,
reconcileGranolaWebhookFolders,
} 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…
},
});
```

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";

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`

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

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.

## Working on it

```sh
Expand All @@ -55,8 +203,7 @@ bun run test
bun run build
```

See [CONTRIBUTING.md](./CONTRIBUTING.md) for the dependency rule and test conventions,
and [ARCHITECTURE.md](./ARCHITECTURE.md) for the design.
See [CONTRIBUTING.md](./CONTRIBUTING.md) and [ARCHITECTURE.md](./ARCHITECTURE.md).

## License

Expand Down
Loading