npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@odla-ai/granola

v0.1.1

Published

Pull Granola meeting notes into odla-db and the odla CRM: a typed client for Granola's public API, Standard Webhooks verification, an idempotent mirror of notes/transcripts/folders, and a CRM projection that turns attendees into records and each note into

Readme

@odla-ai/granola

Pull Granola meeting notes into odla-db and the odla CRM.

A typed client over Granola's public API, Standard Webhooks verification, an idempotent mirror of notes, transcripts and folders, and a CRM projection that turns attendees into records and each note into a meeting activity.

No runtime dependency beyond @odla-ai/crm, which only the ./crm entry reaches for — the database and the CRM are both injected structurally.

What it does

Two triggers feed one ingest path:

| Trigger | For | Entry point | |---|---|---| | Webhook | Live updates | createGranolaRoutes() | | Incremental poll | Backfill and reconciliation | syncNotes() |

Both land in ingestNoteById(), which mirrors the note into odla-db and — when a CRM port is wired — projects it onto CRM records. Everything is idempotent: a redelivered webhook and a poll that sweeps the same window twice converge on identical rows.

Setup

You need a Granola API key (Settings → Connectors → API keys in the desktop app; Business plan or above). Store it as a vault secret, never a wrangler var — it reads every note its scopes cover.

odla-ai secrets set granola_api_key --env dev --stdin

Install the schema and its deny-all rules:

import { GRANOLA_SCHEMA, GRANOLA_RULES } from "@odla-ai/granola";
// POST GRANOLA_SCHEMA to /app/:id/schema, GRANOLA_RULES to /app/:id/admin/rules

The namespaces are deny-all by design. They hold meeting transcripts, which are the most sensitive rows most apps will store, so no end-user credential reads them — access goes through your worker.

Mirroring notes

import { initGranola, syncNotes } from "@odla-ai/granola";

const client = initGranola({ apiKey: env.GRANOLA_API_KEY });
const result = await syncNotes({ db }, client);
// { ingested: 12, failed: [], lastUpdatedAt: 1769000000000, truncated: false }

syncNotes walks updated_after from a stored watermark. A note that fails to ingest holds the watermark behind itself, so the next run retries it rather than reporting success over a note it dropped.

Read it back:

import { getStoredNote, getStoredTranscript, listNotes } from "@odla-ai/granola";

const recent = await listNotes({ db }, { updatedAfter: Date.now() - 7 * 864e5 });
const transcript = await getStoredTranscript({ db }, recent[0].noteId);

Projecting onto the CRM

import { ingestNoteById } from "@odla-ai/granola";
import { crmPort } from "@odla-ai/granola/crm";

const port = crmPort({ crm, db });
await ingestNoteById({ db }, client, noteId, {
  port,
  mapping: { personType: "person", companyType: "company" },
});

Every attendee becomes a person record and every email domain a company; the note lands once per person as a kind: "meeting" activity carrying the summary and a link back to Granola. Records resolve through crm_record_origin under the granola source id, so the same attendee maps to the same record on every later note.

Mapping options

| Option | Default | Notes | |---|---|---| | personType | "person" | Record type for an attendee | | companyType | "company" | null skips company records entirely | | companyRelation | unset | Opt-in. A CRM rejects a relation its config does not declare, so there is deliberately no default | | activityKind | "meeting" | One of crm's ACTIVITY_KINDS | | ignoredCompanyDomains | [] | Pass CONSUMER_EMAIL_DOMAINS to keep gmail.com and friends from becoming companies | | ignoredEmails | [] | Skip an attendee entirely — your own team, a notetaker bot |

Receiving webhooks

Register the endpoint and store the signing secret immediately — it is returned exactly once and can never be read back:

const endpoint = await client.createWebhookEndpoint({
  url: "https://your-app.dev/api/granola/webhook",
  scopes: ["personal", "public"],
});
// endpoint.signing_secret → granola_webhook_secret

Mount the receiver in your worker:

import { createGranolaRoutes } from "@odla-ai/granola";

const granolaRoutes = createGranolaRoutes({
  deps: { db },
  client,
  signingSecret: env.GRANOLA_WEBHOOK_SECRET,
  port,
});

export default {
  async fetch(request, env, ctx) {
    return (await granolaRoutes(request)) ?? handleEverythingElse(request);
  },
};

Deliveries are verified per the Standard Webhooks spec: HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body}, compared in constant time, with a timestamp tolerance so a captured body cannot be replayed indefinitely. A delivery that fails verification is refused with 400 and never ingested. One that verifies but fails to ingest gets 500 — and because the event is only recorded after a successful ingest, Granola's retry does the work rather than being skipped as a duplicate.

Granola's delivery timeout is not a good place to page a long transcript. To hand the work to a queue instead:

createGranolaRoutes({ deps: { db }, client, signingSecret, ingest: false,
  onEvent: (event) => env.QUEUE.send(event) });

Subscribe to both note.generated and note.access_granted: an already-generated note that is shared with you later arrives as the latter.

Notes on the upstream API

  • Only notes with a generated AI summary and transcript are returned. Notes still processing do not appear in List Notes and 404 from Get Note.
  • Rate limits are a 25-request burst per 5s window, sustained at 5/s. The client retries 429s and 5xx with backoff that honours Retry-After.
  • A transcript too large to inline answers 413 TRANSCRIPT_TOO_LARGE; fetchNoteWithTranscript recovers by paging it separately.
  • created_by on a workspace-managed webhook endpoint is genuinely null.

License

MIT