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/crm

v0.9.0

Published

A flexible CRM layer for odla apps — configurable record types (people, companies, anything), pipelines with gated transitions, tags, notes and follow-ups, email consent + audited sends, and saved views, all on odla-db with a mountable route factory and a

Downloads

2,188

Readme

@odla-ai/crm

A flexible CRM layer for odla apps — configurable record types (people, companies, anything), pipelines with gated transitions, tags, notes and follow-ups, consent-gated email with audited sends, and saved views, all on odla-db with a mountable route factory and an admin UI kit over @odla-ai/ui.

npm i @odla-ai/crm

Agentic experiment. This package is built and maintained by AI agents working from bounded runbooks with human review. Review its documented guarantees before relying on it.

Ask the runbooks first. odla's operational procedures live in a database, not in this file: npx @odla-ai/cli runbook ask "<question>" returns the current steps, and unlike anything written here it cannot be out of date. Use it before searching the web or working from memory. This README and the JSDoc in the shipped .d.ts are the version-matched API reference; a runbook is the procedure. Most tasks need an answer from both.

The shape

The CRM is a schema + deny-all rules + a route factory + an admin UI kit — not a service you run.

  • Record types are configuration, not schema. Every type shares one generic crm_record namespace: promoted columns for the hot paths every type has (name, stage + position, rank, score, contactability, billing cache), nine typed slot columns (s1s4, n1n3, d1d2) that index each type's custom sortable/filterable fields, the complete field payload in a json column, and a denormalized FTS haystack rebuilt on every write.
  • Tags and relations are natural-key join rows (recordId:tag, fromId:relation:toId) — idempotent upserts for human-curated cohorts and graph edges. Machine federation identity and delivery state use separate structured origin/delivery rows, so editing a display tag cannot break a retry contract.
  • Consent is a table-driven state machine. Suppression is admin-sticky, an unsubscribe is undone only by the contact's own opt-in, hard bounces land from anywhere, and unsubscribed still receives transactional mail. Marketing sends carry {{unsubscribeUrl}} and RFC 8058 one-click headers automatically.
  • All access is worker-mediated by design. Slot mapping, searchText rebuilds, pipeline gates, and validation are functions of your config — CEL rules cannot express them — so every crm_* namespace installs deny-all and createCrmRoutes (admin key, behind your own authorize) is the only door.
  • Zero runtime dependencies. The db client and email transport are injected structurally: a real @odla-ai/db AdminDb and a thin wrapper over @odla-ai/email's sendMessage satisfy the interfaces.

Quick start

import { defineCrm, createCrmRoutes } from "@odla-ai/crm";
import { init } from "@odla-ai/db";

export const crm = defineCrm({
  types: {
    person: {
      label: "Person",
      emailField: "email",
      fields: {
        name: { type: "string", required: true },
        email: { type: "email" },
        state: { type: "enum", options: ["UT", "CA"], slot: "s1" },
        netWorth: { type: "number", slot: "n1" },
      },
      pipeline: {
        stages: [{ id: "prospect" }, { id: "applied" }, { id: "member", terminal: true }],
        transitions: { prospect: ["applied"], applied: ["member"] },
        gates: { member: (r) => (r.emailStatus === "none" ? "needs an email first" : null) },
      },
      facets: { identity: true, email: true, rank: "manual" },
      onTransition: { member: async (r) => promoteToMember(r) }, // post-commit hook
    },
    company: { label: "Company", fields: { name: { type: "string", required: true } } },
  },
  relations: { works_at: { from: "person", to: "company", label: "works at" } },
  templates: {
    welcome: { vars: ["firstName"], class: "transactional",
      defaults: { subject: "Welcome {{firstName}}", text: "You're in." } },
    newsletter: { vars: ["firstName", "unsubscribeUrl"], class: "marketing" },
  },
});

const db = init({ appId, adminToken, endpoint }); // @odla-ai/db admin client
const crmRoutes = createCrmRoutes({
  crm,
  db,
  authorize: async (req) => ((await isAdmin(req)) ? { userId: await adminId(req) } : null),
  // Registry-owned read assertion; it cannot authorize a CRM mutation.
  authorizeDiscussionReferences: async (req) => {
    const actor = await verifyRegistryCrmReference(req);
    return actor ? { userId: actor.id, discussionReferenceScope: "all" } : null;
  },
  envName: env.ENV_NAME, // only exactly "production" delivers real email
  baseUrl: "https://app.example.com",
  from: env.EMAIL_FROM,
  sender: { send: (payload) => sendMessage(mySender, payload) }, // optional
});

export default {
  async fetch(req: Request): Promise<Response> {
    return (await crmRoutes(req)) ?? myAppRoutes(req); // null = not a crm path
  },
};

Compose reusable and host-owned CRM modules before resolving them:

import { composeCrmConfigs, defineCrm } from "@odla-ai/crm";

const crm = defineCrm(composeCrmConfigs(peopleConfig, portfolioConfig));

Duplicate type, relation, or template ids fail instead of silently replacing one module. A type may set workspace: "portfolio" (or another host-defined string) as a generic UI grouping hint; core CRM CRUD and authorization do not interpret that value.

GET /api/crm/discussion-references?q=… searches product-owned records and activities; exact kind + id lookup proves the current row and parentage. Results contain canonical kind/id/label, bounded summary/status/destination, and a same-origin odla-ref link that opens the record or focused activity. For a Registry caller, authorizeDiscussionReferences must verify the exact crm product, crm.read, canonical audience, app incarnation, environment, principal, and—when present—manager plus grant revision. Ordinary agent handshakes exclude CRM; an owner must explicitly opt into read-only crm.read. This scope cannot edit records, send mail, or approve anything.

Put the resolved CRM in an app module both the Worker and checked-in odla config can import, then declare the project-specific integration:

// odla.config.mjs
import { createCrmIntegration } from "@odla-ai/crm";
import { crm } from "./src/crm.js";

export default {
  app: { id: "my-app", name: "My App" },
  services: ["db"],
  integrations: [createCrmIntegration(crm, {
    basePath: "/api/crm",
    notificationEmail: "[email protected]",
  })],
  links: { dev: "https://dev.example.com" },
};

odla-ai provision collision-checks and merges the CRM schema/rules, then creates crm_config only when absent. odla-ai doctor checks the composed namespaces/rules/seed contract offline; odla-ai smoke uses links.<env> to verify the mounted records route returns 401 without credentials. Mounting createCrmRoutes and supplying authorize remain app-owned source work.

And the admin surface, from @odla-ai/crm/ui (native Preact, --ui-* app-tier styling). Load the CRM sheet after the selected UI theme:

import "@odla-ai/ui/themes/paper/tokens.css";
import "@odla-ai/ui/themes/paper/ui.css";
import "@odla-ai/ui/themes/paper/scope.css";
import "@odla-ai/ui/index.css";
import "@odla-ai/crm/ui.css";
import { CrmClient } from "@odla-ai/crm";
import { CrmWorkspace } from "@odla-ai/crm/ui";

const client = new CrmClient({ headers: async () => ({ authorization: `Bearer ${await token()}` }) });

function People() {
  return (
    <CrmWorkspace
      crm={crm}
      client={client}
      type="person"
      recordId={route.recordId}
      detailTab={route.detailTab}
      onRecordIdChange={setRecordId}
      onDetailTabChange={setDetailTab}
    />
  );
}

CrmWorkspace is the standard responsive list/detail composition. RecordPanel remains available independently and accepts an extensible, controlled tab registry for application-specific operations.

Preserve a branded CRM workspace

CrmWorkspace owns loading, search, pagination, selection, responsive pane switching, and record refresh. Render slots let the host retain its information design:

<CrmWorkspace
  crm={crm}
  client={client}
  type="person"
  recordId={route.recordId}
  detailTab={route.detailTab}
  hrefForRecord={(record) => `#people/person/${record.id}/profile`}
  hrefForList="#people/person"
  hrefForDetailTab={(tab) => `#people/person/${route.recordId}/${tab}`}
  renderSummary={({ query }) => <Summary total={query.page?.total ?? 0} />}
  renderMaster={({ defaultMaster }) => <BrandedRail>{defaultMaster}</BrandedRail>}
  renderDetailHeader={({ detail }) => <RecordHeading record={detail.record} />}
  renderDetail={(context, defaultDetail) => (
    <RelationshipProfile record={context.detail.record}>
      {defaultDetail}
    </RelationshipProfile>
  )}
/>

Record links, the mobile back control, and detail tabs become native anchors when their hrefFor* builders are supplied. Record panels mount only the active tab by default, preventing hidden communications or scheduling panels from fetching data. renderDetail receives the fully composed default record panel, so a host can wrap or replace the entire detail experience while CRM continues to own loading, selection, mutation state, and tab composition.

For records whose displayed pipeline mirrors another authoritative workflow, delegate transitions instead of calling the CRM route directly:

<CrmWorkspace
  {...props}
  requireLifecycleAdapter
  lifecycle={{
    transitionStage: ({ record, from, to }) =>
      operations.transition({ record, from, to }),
  }}
/>

Without requireLifecycleAdapter, generic CRM collections retain the client.setStage default. With it, a missing adapter fails closed. Custom tabs may declare visible(detail) and disabled(detail) predicates; invisible panels are neither rendered nor mounted.

What ships where

| Concern | Where it lives | | --- | --- | | Types, pipelines, gates, scorers, template contracts | Code — defineCrm (validated at boot) | | Template copy, enable flags, destination addresses | Data — the crm_config row (owner-editable) | | Records, tags, links, activities, channels, send audit, views | odla-db (CRM_SCHEMA namespaces) | | Cross-system record identity and latest target delivery state | crm_record_origin and crm_record_delivery | | Money | Nowhere here — BillingProvider is an optional port an app-owned client or reusable provider package may implement. The record carries cache columns only. | | Meetings/scheduling | @odla-ai/calendar | | Clerk role changes | Your onTransition hooks (via @odla-ai/auth-clerk) |

Guarantees & limits

  • Every write accepts a mutationId for exactly-once application; webhook-style callers key on it ({provider}:{eventId}, consent:{token}:{event}, email:{dedupeKey}).
  • upsertRecordOrigin binds one source site + source record id to one local record and refuses remapping. recordDeliveryAttempt retains the latest per-record/per-target status and bounded error while incrementing attempts. GET /api/crm/records/:id includes origins and deliveries; the standard record UI exposes them in a Network tab only when data exists.
  • Discussion reference projections are read-only and product-authorized; a stored ref or odla-ref link is navigation, never CRM mutation authority.
  • Send-path failure rows are written unconditionally, so a retry's success is never swallowed; dev environments redirect to debugEmail or fall back to log-only.
  • Sorting is server-side on exactly one indexed field (odla-db's contract): promoted columns and slotted fields only — the UI kit marks exactly those sortable.
  • List windows cap at 200 rows; tag cohorts resolve up to 1000 members; unsubscribe GETs never mutate (mail scanners prefetch).

MIT © odla