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

v0.31.4

Published

A leader/follower foundation for branded membership sites: shared CRM, admin, auth, payments, booking, and explicit record delivery from one defineChapter config.

Readme

@odla-ai/chapter

A foundation for membership sites. One defineChapter({...}) config resolves the shared application engine — join/apply → Stripe membership → Google booking → member area — plus an admin console and CRM, or an admin-only hub, on odla-db + Clerk + @odla-ai/crm + calendar + email. The host still builds the public pages, routing, and brand presentation; Chapter supplies their application mechanics.

npm install @odla-ai/chapter

Agentic experiment. Built and maintained by AI agents from bounded runbooks with human review. Review the documented guarantees before relying on it.

Pre-1.0. The member surface, operational worker, standard admin console, and leader → follower record delivery ship. APIs may still tighten before 1.0; commit the lockfile and run schema/route contract tests after every update.

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.

Agent runbooks

The API examples below are not a website-build plan. An LLM or coding agent must choose and read one of these version-matched runbooks from the installed npm package before changing a site:

  • Empty repo or a genuinely new product: read runbooks/greenfield.md completely. It covers the brand brief, public information architecture, Chapter configuration, member/admin surfaces, Cloudflare/ODLA setup, and visual + journey acceptance.
  • Existing or deployed site: read runbooks/adopt-existing.md completely. It preserves the existing product, URLs, visual identity, data, auth, jobs, and rollback boundary while Chapter becomes the primary implementation.

Do not combine the two flows. A provisioned canary is not a completed adoption, and a greenfield build does not need migration machinery. Both runbooks keep secrets out of source and require development proof before production.

The repository also contains a build-tested generic Preact reference host. It is the executable companion to the greenfield runbook: site-owned global navigation, public routes, brand, voice, join fields, and member framing wrap Chapter-owned application, admin, CRM, payment, and scheduling behavior.

The shape

  • One config, two profiles. defineChapter() validates at import and returns a resolved engine. mode: "chapter" enables the complete public-member application profile; mode: "hub" is admin-only and CRM-focused. The mode gates the member/join/payment route surface; everything else (auth, CRM, chrome, provisioning) is shared. chapter.brand and chapter.network are resolved from this same config, so the browser UI does not need a second identity or follower registry.

  • The worker is the package. chapterWorker({ chapter }) is the whole Cloudflare ExportedHandler. It serves /api/health, /api/config, /api/me, /api/crm/*, /api/network/{shared,snapshot,records}; configured leaders also get /api/admin/network/{targets,push,rollup,records,notes}. An enabled leader formation intake adds /api/formation/{config,applications}. Chapter mode adds the public member surface (/api/join-config, /api/join/resume, /api/applications, /api/schedule/{slots,book}, /api/payments/subscription, /api/webhooks/stripe). The /api/admin/* handlers are registered in both modes; expose only the sections compatible with the selected profile and its provisioned namespaces. The Worker then falls back to static assets. Your src/worker.ts is ~3 lines. Observability is a host concern — wrap it with withObservability from @odla-ai/o11y. Unknown /api/* paths terminate as JSON 404 responses rather than falling through to an SPA document. Operational values that owners may change (prices, policy copy, email templates, scheduling rules) are read at runtime from a single odla-db groups row. Brand identity and build-time tokens remain in the checked-in Chapter config.

  • A route seam, not a black box. chapterWorker({ chapter, routes }) runs your handlers before the built-ins (add routes, or override/alias a path), each receiving the same context the built-ins get. The worker entry also exports createWorkerContext + the WorkerContext/Route types, so a wrapping worker reuses chapter's JWT verify, db client, and role resolution (verifyUser/makeDb/roleFor/isAdmin) instead of duplicating them.

  • Source-aware auth. A JWT role-claim ladder (provisional → member → admin) or an odla-db admins allowlist, plus a read-only superAdmins tier — selected by auth.source, defaulting per mode. Escalation guards (canChangeRole) are package-enforced.

  • Correctness is packaged, not per-site. Replay-deduplicated email (sendTemplated/isAlreadySent; concurrent sends still need a serialized outbox/provider idempotency for a true exactly-once guarantee), a non-prod delivery fail-safe (planDelivery), stable application replay identity (one UI submission key and the same resumable canonical id), status-never-backwards (canTransition), Stripe webhook integrity (verifyStripeSignature) with the webhook as the authoritative writer of paid/refunded, one-subscription-per-application idempotency, meetings-as-canonical booking (a rebooking reschedules the event, preserving the Meet link), Google-edit adoption (reconcileMeetings), and a one-way CRM projection.

  • Provisioning is declarative. createChapterIntegration(chapter) composes the crm namespaces + the chapter namespaces (applications, groups, meetings, emailLog, plus the auth tables) + a guarded group-row seed. Drop it in odla.config.mjs integrations: [...].

  • Network movement is explicit. A leader lists follower origins and per-target field allowlists in network.targets; secret names live in config and secret values stay in the tenant vault. The standard record panel then shows “Share with …” actions for compatible people, businesses, deals, or other CRM types. The receiver validates the payload against its own CRM config and stores a structured origin row, so retries update the same local record. Delivery state is structured too; display tags remain a migration and cohort affordance rather than the machine identity contract. Shared identity/business fields move; each site's pipeline, account, and billing state remain authoritative locally.

  • The UI kit, adoptable in pieces. Three entries, split on the dependency boundary so you never pay for what you don't use:

    • @odla-ai/chapter/ui/memberPreact-only: SlotPicker, the date/timezone helpers, JoinIsland (form → payment → booking), MembersArea, Rescheduler, PaymentStep, <BrandStyle>. It never imports @odla-ai/auth-clerk or @odla-ai/crm, so you can adopt a single presentational component without the Clerk browser SDK. (A bundle-graph test enforces this.)
    • @odla-ai/chapter/ui/admin — the Clerk-gated console ChapterAdmin + the workspace catalog. In chapter mode, <ChapterAdmin chapter={chapter}/> defaults to just three top-level workspaces: Dashboard, People, and Settings. Operational detail belongs in nested page tabs; CRM record operations belong in record tabs. Pass workspaces to transform or replace that information architecture. This entry needs auth-clerk + crm/ui.
    • @odla-ai/chapter/ui — the full barrel, for back-compat.

    Authored and shipped against Preact. Brand tokens (brandTokens/<BrandStyle>) re-skin all of it from brand (now light and dark, via brand.palette + brand.paletteDark).

  • Voice is part of the brand. copy is a recursively partial ChapterCopy; defineChapter() resolves it into a complete chapter.copy contract. Packaged join, member, and admin surfaces read that contract, so a follower can own terminology and tone without forking behavior. Text remains serializable; use render slots when the host needs markup or a different composition.

  • The host owns global navigation. Admin defaults to chrome="embedded" and three familiar workspaces. Dashboard/Billing and Calendar/Email remain link-backed page tabs; record operations remain record tabs. A host can keep one site header around public, join, member, and admin routes without Chapter introducing another top-level navigation model.

Theme tokens and scoped branding

The UI reads its colors from --ui-* design tokens whose values come from a theme layer, not @odla-ai/ui/index.css. For an admin surface isolated from the public site, import one scoped theme, the shared component sheet, and the CRM layout:

import "@odla-ai/ui/fonts/plex.css";
import "@odla-ai/ui/themes/paper/scope.css";
import "@odla-ai/ui/index.css";
import "@odla-ai/crm/ui.css";

ChapterAdmin guards against this at runtime: if a .panel has no background (the theme layer is missing), it renders a loud red banner at the top of the console instead of failing silently. Set brand.theme, brand.colorScheme, and semantic brand.tokens / brand.tokensDark for normal configuration. brand.palette / brand.paletteDark remain the low-level custom-property escape hatch. Chapter scopes these overrides to [data-chapter-admin], so an admin brand cannot recolor the document root or vendor sign-in UI.

brand.accent selects a named data-ui-accent family supplied by the chosen theme. Each AdminWorkspace may set its own accent for a scoped page-level variation. For a fully custom palette, compile the brand book through the pure token entry and adapt it structurally:

import { compileBrandTokens } from "@odla-ai/brand/tokens";
import { chapterBrandFromTokens } from "@odla-ai/chapter";

const compiled = compileBrandTokens({ swatches });
const brand = chapterBrandFromTokens(compiled, {
  theme: "paper",
  wordmark: "Example Chapter",
});

This carries the compiler's complete light map, derived dark map, and invert map into Chapter without adding an @odla-ai/brand runtime dependency to Chapter itself.

API quick start

// src/chapter.config.mjs
import { defineChapter } from "@odla-ai/chapter";

export const chapter = defineChapter({
  id: "example-chapter",
  name: "Example Chapter",
  mode: "chapter",
  brand: {
    badge: "EX",
    wordmark: "Example Chapter",
    tagline: "Capital and craft for durable local businesses.",
    theme: "paper",
    colorScheme: "light",
    tokens: {
      accent: "#2f6f4f",
      accentStrong: "#244f3b",
      accentSoft: "#dfece4",
    },
    fonts: { display: "GT Sectra" },
  },
  copy: {
    join: { form: { submit: "Start the conversation" } },
    admin: { workspaces: { people: "Community" } },
  },
  prices: { standardCents: 100000, foundingDiscountCents: 10000 },
  emails: { notificationEmail: "[email protected]" },
  account: "none",
});
// src/worker.ts
import { chapterWorker } from "@odla-ai/chapter/worker";
import { chapter } from "./chapter.config.mjs";
export default chapterWorker({ chapter });
// src/app/admin.tsx — brand + familiar workspaces come from the same config.
import "@odla-ai/ui/fonts/plex.css";
import "@odla-ai/ui/themes/paper/scope.css";
import "@odla-ai/ui/index.css";
import "@odla-ai/crm/ui.css";
import { render } from "preact";
import { ChapterAdmin } from "@odla-ai/chapter/ui/admin";
import { chapter } from "../chapter.config.mjs";

render(<ChapterAdmin chapter={chapter} />, document.getElementById("admin-root"));
// odla.config.mjs
import { createChapterIntegration } from "@odla-ai/chapter";
import { chapter } from "./src/chapter.config.mjs";
export default {
  app: { id: chapter.id, name: chapter.name },
  services: chapter.services,
  integrations: [createChapterIntegration(chapter)],
};

That is the reusable application shell. Public pages remain site-owned: start from the approved product and brand brief plus @odla-ai/ui marketing components. Do not copy a reference site's identity or fork auth, admin routing, CRM, payment, booking, or account logic to achieve a different brand.

Join and member composition

JoinIsland keeps application mechanics while exposing presentation seams:

<JoinIsland
  config={joinConfig}
  renderStepHeader={({ state }) => <FlowHeading step={state.step} />}
  renderSubmit={({ disabled, submitting }) => (
    <BrandedSubmit disabled={disabled}>{submitting ? "Sending…" : "Apply"}</BrandedSubmit>
  )}
  renderDone={({ booked, membersHref }) => (
    <Confirmation booked={booked} membersHref={membersHref} />
  )}
  payment={{
    appearance: stripeAppearance,
    fonts: stripeFonts,
    renderPriceLines: (lines) => <PriceSummary lines={lines} />,
  }}
>
  <ApplicationFields />
</JoinIsland>

initialState accepts trusted server state. On a browser redirect, JoinIsland reads the application capability from the query string and asks GET /api/join/resume for the canonical payment, booking, or done state; raw redirect_status and reschedule values never choose a UI step. Supply loadResume when a host stores the capability elsewhere.

MembersArea.renderProvisional receives the loaded application, authenticated API function, reload callback, apply URL, and defaultContent, so the host can wrap or replace the provisional card just as admin workspaces can be composed. Nested admin/page/record tabs use fragment anchors by default, not query-string state.

Leader portfolio and formation

leaderCrmConfig() composes generic portfolio records into an existing CRM config without replacing its people, businesses, pipelines, templates, or relations. Duplicate ids fail at startup:

import { defineChapter, leaderCrmConfig } from "@odla-ai/chapter";

const existingCrmConfig = {
  types: {
    person: {
      label: "Person",
      labelPlural: "People",
      nameField: "name",
      emailField: "email",
      fields: {
        name: { type: "string", required: true },
        email: { type: "email" },
      },
    },
  },
};

export const chapter = defineChapter({
  id: "example-hub",
  name: "Example Hub",
  mode: "hub",
  crm: leaderCrmConfig({ base: existingCrmConfig }),
  formation: {},
  network: {
    targets: [{
      id: "example-chapter",
      name: "Example Chapter",
      url: "https://chapter.example.com",
      fields: { person: ["name", "email"] },
    }],
  },
});

The leader may make chapter its own durable aggregate while retaining the preset's required identity and relations. chapter.fields and chapter.facets merge with the defaults; chapter.pipeline replaces the generic proposed/forming/active lifecycle. The final configuration is still validated by defineCrm():

crm: leaderCrmConfig({
  base: existingCrmConfig,
  chapter: {
    fields: {
      marketKey: { type: "string", label: "Market key", slot: "s3" },
      signalScore: { type: "number", label: "Signal score", slot: "n1" },
    },
    pipeline: {
      stages: [{ id: "investigation" }, { id: "growth" }],
      transitions: { investigation: ["growth"] },
    },
  },
}),

The preset adds chapter, chapter_application, and deal record types in the portfolio workspace, with formation, operating, and deal-flow pipelines. It also composes relations to base person and company types when present. These are ordinary CRM records: the host may add fields, types, render slots, and relations by composing its own config before passing it to Chapter.

formation: {} opts into a host-rendered public application:

  • GET /api/formation/config returns only configured public field metadata.
  • POST /api/formation/applications accepts only those fields and creates a normal chapter_application CRM record.
  • A stable base64url submissionId makes ambiguous retries resolve to the same record. Hosts must generate one per form journey and retain it until the response is known.
  • Request size, string length, required fields, and public field names are bounded by formation; internal fields such as notes are not accepted.

Formation is off unless configured. Chapter intentionally does not ship a public form design: the site renders the fields in its own voice and brand.

For a hub with network targets, the standard admin catalog is Dashboard, Network, People, Portfolio, and Settings. Dashboard Overview renders aggregate network health; People contains CRM types whose workspace is absent or "people"; Portfolio contains types marked workspace: "portfolio". Operational details remain nested page tabs and record operations remain record tabs. A workspaces transform can preserve or extend this catalog just like the standard follower console.

Follower records, private notes, and shared notes

A follower must opt in before a leader can browse any of its CRM records. The follower owns both the signed-reader allowlist and the exact fields that may leave:

export const chapter = defineChapter({
  id: "example-chapter",
  name: "Example Chapter",
  account: "none",
  prices: { standardCents: 100_000 },
  emails: { notificationEmail: "[email protected]" },
  network: {
    readers: [{
      id: "example-hub",
      fields: {
        person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
      },
      // Separate write consent: browsing never implies note permission.
      sharedNotes: ["person"],
    }],
  },
});

GET /api/network/records requires the edge HMAC and rejects signed senders not listed in network.readers. Results are capped at 50 records, offset-paged, search-bounded, and projected to the reader's field list. Pipeline stage and record timestamps are the only metadata outside that list; application narrative, billing, account state, activities, and notes do not cross unless the follower explicitly names a corresponding CRM field.

The leader's authenticated GET /api/admin/network/records proxy signs the follower request server-to-server, so neither the browser session nor the vaulted edge secret crosses sites. The Network detail view has two deliberate lanes:

  • GET/POST /api/admin/network/notes stores private annotations in the leader's deny-all networkNotes namespace. They are keyed to the follower's stable target/type/record identity and never leave the leader.
  • GET/POST /api/admin/network/shared-notes crosses the signed edge only when both the leader target and follower reader declare sharedNotes for that record type. The follower appends the note to its normal CRM activity feed, where its own admins see it. The leader can read back only notes that it shared; follower-private CRM activity is never returned.

Leader → follower delivery

The leader declares where records may go and exactly which fields each follower receives:

export const chapter = defineChapter({
  id: "example-hub",
  name: "Example Hub",
  mode: "hub",
  network: {
    targets: [{
      id: "example-chapter",
      name: "Example Chapter",
      url: "https://chapter.example.com",
      binding: "EXAMPLE_CHAPTER",
      fields: {
        person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
        company: ["name", "domain", "industry", "location", "linkedin"],
      },
      sharedNotes: ["person"],
    }],
  },
});

Treat the network as websites connected by explicit delivery edges:

  • each website is a node with its own appId, ODLA tenants/keys, CRM config, Clerk application, publishable keys, issuer, roles, and local users;
  • each network.targets[] entry is a directed edge from one leader to one follower: { id, name, url, binding?, secretName?, fields };
  • the payload is a versioned, allowlisted business record { version: 2, source: { siteId, recordId }, type, input }, never an identity-provider session, another site's pipeline/account/billing state, or an ODLA admin key;
  • retries address the follower record by leader provenance, so the same edge updates rather than duplicates.

The authorization chain has three separate hops. The leader's browser sends its own Clerk session only to the leader's admin route. The leader Worker authorizes that local operator, reads the edge's share secret from its own vault, and signs the exact method, path/query, sender id, timestamp, nonce, and body digest. The follower validates that HMAC envelope against its vaulted copy, requires the signed sender to match the v2 payload source, validates the payload against its own CRM config, and writes with its own server-side ODLA_API_KEY. The share secret itself is never sent. A follower Clerk token is never needed, the leader never receives the follower's database key, and sites do not share a Clerk application.

When both sites are Cloudflare Workers, configure binding and add the matching service binding to the leader's Wrangler environment. Cloudflare does not reliably dispatch same-account workers.dev subrequests into the target Worker; Chapter therefore signs against url but sends through the bound Worker:

{
  "env": {
    "dev": {
      "services": [{
        "binding": "EXAMPLE_CHAPTER",
        "service": "example-chapter-dev"
      }]
    }
  }
}

The example assumes the follower sets its development Worker name explicitly to example-chapter-dev. If it instead uses Cloudflare's named-environment addressing, declare the base service plus "environment": "dev". The binding name is public configuration, not a credential. If a configured binding is missing at runtime, the target fails explicitly instead of falling back to a potentially misrouted public fetch.

Vault the same random value as network_share_secret in the follower and as network_share_example_chapter in the leader (or set a different secretName). No secret is placed in source, browser data, or provisioning config. The standard collection drawer discovers the target and calls the admin-gated push route. A follower must declare the receiving CRM type/fields; otherwise it rejects the record cleanly instead of dropping fields.

The signed v2 protocol is the contract for new integrations. The follower temporarily accepts the old bearer/v1 envelope so an existing edge can be upgraded without downtime; new leaders must not use it.

Every received v2 record gets a crm_record_origin row keyed by the upstream site and record id. Every push attempt gets a crm_record_delivery row keyed by the local record and target, including attempt count, current status, payload version, remote record id, and bounded latest error. Record detail responses include both collections and the standard record drawer adds a Network tab when either exists.

GET /api/network/snapshot is an HMAC-signed, aggregate-only follower endpoint. It returns type totals and pipeline-stage counts, never record fields, people, sessions, Clerk tokens, share secrets, or ODLA keys. The leader's admin-gated GET /api/admin/network/rollup fans out to every configured target, verifies that each returned site id equals the target id, and returns combined totals plus explicit per-target failures. Consequently each target id must equal the follower's chapter.id.

Existing follower CRMs must opt into the shared graph

When crm is omitted, chapter's default already contains compatible person and company types plus a works_at relation. Passing a custom CRM replaces that default; chapter does not merge missing types or fields into it. A follower that wants both people and businesses must therefore declare compatible types itself:

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

const crm = defineCrm({
  types: {
    person: {
      label: "Person",
      labelPlural: "People",
      nameField: "name",
      emailField: "email",
      fields: {
        name: { type: "string", label: "Name", required: true },
        email: { type: "email", label: "Email" },
        firstName: { type: "string", label: "First name" },
        lastName: { type: "string", label: "Last name" },
        phone: { type: "string", label: "Phone" },
        linkedin: { type: "string", label: "LinkedIn" },
      },
    },
    company: {
      label: "Business",
      labelPlural: "Businesses",
      nameField: "name",
      fields: {
        name: { type: "string", label: "Name", required: true },
        domain: { type: "string", label: "Domain / website", slot: "s1" },
        industry: { type: "string", label: "Industry" },
        location: { type: "string", label: "Location" },
        linkedin: { type: "string", label: "LinkedIn" },
        notes: { type: "string", label: "Notes" },
      },
    },
  },
  relations: {
    works_at: {
      from: "person",
      to: "company",
      label: "works at",
      reverseLabel: "team",
    },
  },
});

The follower must declare every field its leader may send. Unknown types or fields fail the request before any CRM write. Record delivery currently moves records, not crm_link relation rows; create or curate works_at links locally.

Custom leader consoles must mount the sharing UI

Automatic “Share with …” actions come from Chapter's standard People workspace. They are present when the console uses <ChapterAdmin chapter={chapter} />. Passing a workspaces array replaces that catalog, so a custom console must deliberately compose it:

import {
  ChapterAdmin,
  defaultAdminWorkspaces,
} from "@odla-ai/chapter/ui/admin";

const workspaces = [
  customOperationsWorkspace,
  ...defaultAdminWorkspaces(chapter),
];

render(
  <ChapterAdmin chapter={chapter} workspaces={workspaces} />,
  document.getElementById("admin-root"),
);

If the custom console renders its own record drawer instead, mount NetworkShareActions inside that drawer:

<NetworkShareActions
  recordId={record.id}
  recordType={record.type}
  getToken={sectionContext.getToken}
/>

The component discovers compatible targets through GET /api/admin/network/targets; it never receives follower secrets in browser data.

Verify delivery in development before production

Use distinct development tenants and follower origins for the first delivery:

  1. Confirm every target id exactly equals the follower's chapter.id. For Cloudflare Workers, confirm its binding exists in the leader's development Wrangler environment. Vault one random value as network_share_secret in the follower and under the target's resolved secretName in the leader.
  2. Confirm GET /api/admin/network/targets lists the development follower with the expected compatible record types.
  3. Share one test person and one test business from the leader's record drawer. Confirm each appears in the follower with only allowlisted fields.
  4. Share each record again. The second delivery must update the same follower record, not create a duplicate.
  5. Confirm the follower's pipeline, account, and billing state did not change; those remain locally authoritative.
  6. Change one allowlisted leader field and share again to prove later deliveries update the existing record. A target with the wrong secret must return 401 without writing CRM data.
  7. Prove each browser JWT is accepted only by its own website. Inspect the network request and client bundle to confirm neither site's Clerk token nor either ODLA_API_KEY crosses the delivery edge, and confirm the share secret itself is absent from the request.
  8. Load GET /api/admin/network/rollup through the leader session. Confirm aggregate totals, then break one development edge and confirm that target is reported unavailable without hiding healthy targets.

Only after this contract passes against development origins should the leader target be changed to a production follower origin and the matching production vault values be installed.

Admin navigation defaults to link-backed fragments: /admin/#people/person/record-id/profile. The four segments are workspace, nested view, selected record, and record-detail tab. This keeps meaningful, reloadable URLs without requiring a server-side SPA fallback, and it avoids competing with Clerk's sign-in hash: while signed out, Chapter carries the requested state through the redirect query and canonicalizes it after sign-in. Legacy ?tab= and path links still open, while routing="query" and routing="path" remain compatibility modes.

Admin information architecture and migration

The default follower console deliberately has only three top-level workspaces:

  • Dashboard — Overview and Billing page tabs.
  • People — configured CRM collections as page tabs, then a master/detail record view with Stage, Profile, communications, scheduling, Billing, Notes, Connections, Access, and Sharing record tabs when those capabilities apply.
  • Settings — Calendar and Email page tabs.

A leader hub with configured targets adds Portfolio between People and Settings and uses Dashboard Overview for the aggregate network rollup. A hub without targets retains the minimal People-only default. These defaults compose the same grammar; they do not introduce a second admin shell.

The default chrome="embedded" assumes the host supplies the site header. Use chrome="standalone" for Chapter's packaged masthead or renderHeader for a custom header rendered inside the Chapter theme boundary. editorial, none, topbar, and sections remain compatibility APIs. Do not add operational detail back to the global site navigation.

Only the active workspace, page tab, and record tab mount their content. Inactive operational panels therefore do not fetch, subscribe, or run effects. Their native anchors remain in the document, so every level is still reloadable and participates in browser back/forward navigation.

Preserve an existing People structure

The responsive state and data loading belong to @odla-ai/crm; the branded summary, list presentation, record heading, and specialized panels can remain host-owned:

For an operating site, first preserve the validated workspace whole. A matching people id or “People” label says where to compose; it does not establish feature parity:

const workspaces = (defaults) => {
  if (!defaults.some((workspace) => workspace.id === "people")) {
    throw new Error("expected ChapterAdmin people workspace");
  }
  return defaults.map((workspace) =>
    workspace.id === "people"
      ? { ...workspace, render: (ctx) => <ExistingPeople context={ctx} /> }
      : workspace
  );
};

<ChapterAdmin chapter={chapter} workspaces={workspaces} />;

Match the exact people id and fail closed if Chapter changes its catalog; matching only the displayed label can silently replace the wrong workspace. This keeps Chapter's reviewed workspace metadata and surrounding shell while the host retains its summaries, exploration model, role signals, record operations, and state handling. Replace that adapter incrementally only after the corresponding behavior has an executable parity check. A greenfield site can begin with Chapter's defaults because there is no operating workspace to preserve.

const people = collectionSection({
  crm: chapter.crm,
  type: "person",
  lifecycle: true,
  collapseClosedDetail: true,
  renderSummary: ({ query }) => (
    <PeopleSummary total={query.page?.total ?? 0} />
  ),
  renderMaster: ({ defaultMaster }) => (
    <PeopleRail>{defaultMaster}</PeopleRail>
  ),
  renderDetailHeader: ({ detail }) => (
    <RecordHeading record={detail.record} />
  ),
  defaultDetailTab: "overview",
  renderDetail: (context, defaultDetail) => (
    <RelationshipProfile record={context.detail.record}>
      {defaultDetail}
    </RelationshipProfile>
  ),
  extendRecordTabs: (tabs, context) => replaceRecordPanels(tabs, context),
});

collapseClosedDetail defaults to true: while no record is selected, the collection list uses the full workspace width; after selection, it becomes master/detail. Set it to false only when the empty detail pane contains intentional, useful content. This is an operational usability rule, not a request to preserve an old admin's cramped columns. renderMaster and renderSummary customize content; they do not control pane geometry.

renderMaster receives the loaded query, selection helpers, native record-link builder, and defaultMaster, so a host may wrap the standard list or replace its presentation without taking over fetching. renderDetail receives the fully composed default record panel and can wrap or replace the complete detail experience without taking over CRM state. defaultDetailTab controls the tab selected by native record links. extendRecordTabs supports per-record visible and disabled predicates. Hidden panels do not mount.

For lifecycle: true, Chapter routes stage transitions through the application-authoritative approve/refund/manual-transition endpoints and requires that adapter. A custom operational collection can pass lifecycleAdapter; generic CRM collections continue to use the CRM mutation.

Adapt existing authentication routes

An existing site does not need to rename its config or current-user endpoints:

<ChapterAdmin
  chapter={chapter}
  auth={{
    configPath: "/api/auth/config",
    mapConfig: (body) => ({
      publishableKey: typeof body.publishableKey === "string"
        ? body.publishableKey
        : null,
    }),
    mapCurrentUser: (body) => ({
      ...body,
      authorized: body.role === "admin" || body.superAdmin === true,
    }),
  }}
/>

loadConfig and loadCurrentUser may replace fetching entirely. The normalized current-user object is also available to workspace renderers for presentation decisions; server authorization remains authoritative.

When upgrading a flat console:

  1. Import the scoped UI theme, @odla-ai/ui/index.css, and @odla-ai/crm/ui.css.
  2. Remove top-level Billing, Email, Calendar, or collection links that duplicate the standard nested tabs.
  3. Replace sections with workspaces, or omit it to accept the defaults.
  4. Move host-owned summaries, list presentations, and record headings into collectionSection render slots; compose specialized operations with extendRecordTabs.
  5. Connect application-backed stage changes through lifecycleAdapter; never replace an operational transition with a raw CRM stage write.
  6. Change generated links to adminRouteHref; keep legacy query/path URLs only as inbound compatibility links.
  7. Verify refresh, back/forward navigation, keyboard tab behavior, mobile list/detail switching, and brand containment before deleting old routes.

Adopting into an existing site

For the complete ordered conversion and cutover procedure, use runbooks/adopt-existing.md. The notes below are the package-specific behavior reference, not a complete adoption plan.

A real conversion (the site this was extracted from) went from a 2,094-line worker to 6 lines and deleted ~2,500 lines. The order that worked:

  1. Config first, assert parity BEFORE deleting anything. defineChapter() your site, then diff chapter.schema against your existing schema in a test and require byte-equality. That single assertion is what makes the deletion safe rather than hopeful.
  2. Freeze the old schema as a test fixture (e.g. test/fixtures/legacy-schema.mjs) and keep asserting against it, so an upstream default change fails a test instead of a live provision. It is the only durable guard against drift in a generated schema.
  3. Swap provisioningcreateChapterIntegration(chapter), inert until the next provision run. It supplies schema + rules + seeds, so your odla.config.mjs db block goes away entirely.
  4. Then the worker, keeping every bespoke route as a host route (chapterWorker({ chapter, routes })). Don't hand routes to chapter in the same change as the framework swap.
  5. Override rather than inherit wherever local behavior was a decision.

Behavior deltas to audit

These bite silently — a smoke test won't catch them:

  • Application fields generate the application schema. The resolved application.required and application.optional lists now control both submit validation and the applications entity emitted by defineChapter(). Built-in fields retain their types and indexes; site-defined fields become string attrs. A field removed from the reference form no longer remains accidentally required at db.transact, and a field listed in both arrays is rejected at definition time. Keep package-owned operational attrs such as status out of both lists.

  • Per-field caps. application.defaultMaxLen is 2000. If your form accepts longer input, pass maxLen explicitly or the default starts rejecting it.

  • services default is ["db","calendar","o11y"]; smoke compares config against the platform, so set services explicitly if you don't run the o11y collector.

  • Which email fires from which route. Content and addressing are owner-editable on the groups row; the trigger is config:

    | Template | Fires from | Configurable | |---|---|---| | adminNotification | POST /api/applications (submit) — or the Stripe webhook (first payment) | sends.adminNotification: "submit" \| "payment" \| "never" (default "submit") | | prepEmail | POST /api/schedule/book | template enabled flag | | paymentConfirmation | Stripe webhook, first successful invoice | template enabled flag |

    defineChapter({ /* … */ sends: { adminNotification: "payment" } });

    Re-check this on every chapter upgrade. Wiring a send changes a site's outbound mail with no local diff — release notes call out send changes explicitly for that reason.

  • Account model is an explicit chapter-mode decision. account: "create" makes the Clerk account server-side (so join can say the account is ready), "invite" emails the applicant a Clerk invitation, and "none" provisions nothing. defineChapter() rejects a chapter-mode config that omits account, so forgetting the decision cannot silently disable or enable account provisioning. Both side-effecting models need clerk_secret_key in the tenant vault. Hub mode resolves to inert "none".

  • What lands on the Clerk account. public_metadata is reserved for small browser/session-readable authorization claims such as role. Chapter writes applicationId and the explicitly selected profile fields to backend-only private_metadata; application.profileFields defaults to []. Treat that copy as a bounded account/admin snapshot—the application row and CRM remain canonical. Array fields (focus) are clamped to maxArrayLen (default 100) and non-primitive elements are dropped. "create" writes the private snapshot with the account. Clerk application invitations cannot carry private metadata, so "invite" completes the write on the accepted account's first /api/me request. A one-time application marker prevents repeated Clerk API writes. That repair also removes Chapter's former applicationId and profile keys from public metadata without disturbing role. applicantProfile(chapter, fields) is exported to assert the exact shape in a test before deleting a local override.

  • Email + input validation. The field literally named email is checked against a permissive email shape (400 on "notanemail", so it fails cleanly here rather than at the downstream Clerk create) — set application: { validateEmail: false } to opt out; isValidEmail is exported. A valid application is never newly rejected.

  • CRM enrichment. projectApplicant writes the base identity/contact person. To carry more of the application into the CRM, list application: { crmFields: [...] }. Each name is cross-checked against your crm person type at defineChapter() time and throws if it isn't declared there — an undeclared field would otherwise be dropped by the projection while the base person still lands, making a typo invisible. (The runtime projection still falls back to the base person if a write fails, so an applicant is never lost.) Stage mirroring, billing snapshots and Clerk-identity linking stay yours as host routes.

  • Disclaimer acknowledgement. A truthy disclaimerAck on the submit body (boolean or the string a plain form posts) stamps disclaimerAckAt from the server clock; no ack leaves the attr absent, and a client-supplied disclaimerAckAt is ignored.

    Unlike most data loss this is unreconstructible — you cannot later determine whether someone checked a box — so the submit response always echoes disclaimerAckAt (a number, or null when nothing was recorded). Assert it in one integration test and a join page that quietly stops posting the flag can't go unnoticed.

    requireDisclaimerAck now defaults to true, and a submit with no ack is a 400 instead of a row with no consent. Set it to false deliberately only when the site renders no consent control. Existing adopters must test this before cutover.

  • CRM projection points. chapter projects the person on application submit (projectApplicant), not on booking or on webhook status change. If you mirror pipeline stage into the CRM, keep those routes.

  • Route contracts, not route names. Chapter serves /api/config, /api/join-config, etc. Alias a legacy URL with a host route only after method, auth, request, response shape, units, status, and header parity are proven. Equal values with different JSON contracts are not compatible. For a join flow, adopt JoinIsland end to end or keep an explicit tested adapter; do not merely repoint an existing page at /api/join-config. PaymentStep obtains Stripe's clientSecret, publishableKey, and lineItems from POST /api/payments/subscription, not from the public join-config response.

Install + scope notes

  • Bootstrap the first admin according to the selected auth source. Chapter mode defaults to Clerk claims: set the first operator's Clerk public_metadata.role to admin. If that operator also needs the read-only cross-site tier, add their lowercase email to superAdmins in odla Studio. Hub mode defaults to table auth: add the first operator's lowercase email to admins in Studio. Neither allowlist is ever written by a worker route or provisioning seed; that is deliberate, so the running app cannot grant itself admin. Confirm the result by signing in, not merely by inspecting the row.

  • @odla-ai/auth-clerk is an optional Chapter peer. Only one entry imports it: the worker verifies JWTs with jose via ctx.verifyUser, and @odla-ai/chapter/ui/member does not load the auth package. Install @odla-ai/auth-clerk yourself if (and only if) you adopt @odla-ai/chapter/ui/admin or want the themed sign-in components; reach for @odla-ai/auth-clerk/invitations when you want to send your own branded invitation mail. Importing the full @odla-ai/chapter/ui barrel pulls the admin half, so prefer the narrower entry.

  • Use normal dependency declarations during active development. Install Chapter, its selected peers, and the Preact host toolchain without exact pins. Commit package-lock.json, use npm ci for repeatable installs, record npm ls output in PM evidence, and rerun conformance after updates. The package manifest's dependency and peer ranges are the compatibility contract.

  • --legacy-peer-deps is a diagnostic, not a setting. It suppresses exactly the peer conflict that tells you a pair is unsupported. If you need it, find out why first.

  • The admin operational surface ships (0.16.0). /api/admin/* is now a full membership-operations API, admin-gated (verifyUser + isAdmin):

    • Roster/identity: GET /people (union of $users + applications by email, roles and backend-only profile signals from clerkListUsers), GET /people/access, POST /people/role, POST /crm/sync (backfill/reproject), and POST /clerk/private-profiles/sync?offset=0&limit=50 (bounded, resumable migration/repair from Chapter's former public profile keys).
    • Pipeline/meetings: GET /dashboard (flow counts, stage counts + weekly delta, agenda, live revenue), GET /meetings (reconciled agenda), GET/PUT /scheduling, POST /meetings/:id/{reschedule,cancel}, PATCH /applications/:id.
    • Money: GET /billing (applications ⋈ live Stripe subs; truncated flags a

      100 page instead of losing rows; billingReady:false when no Stripe key is vaulted), POST /applications/:id/{approve,refund}.

    • Email: GET/PUT /group/email, GET /email/log, POST /email/test, GET /people/:id/comms (sent mail + reconstructed calendar invitations).

    Two behaviours are config, not code — the mechanics are chapter's, the decisions are yours (same model as sends):

    defineChapter({ operations: {
      onApprove: { promoteTo: "member", send: "onboardingInvite" }, // promoteTo defaults to the rung below admin; either can be false
      refund:    { allowedFrom: ["paid_pending_vetting"], cancelSubscription: true }, // allowedFrom validated against the pipeline
    }});

    The escalation rules (super-admin tier, self-demotion lockout) are not seams — canChangeRole package-enforces them so a site can't weaken them. approve is the caller onboardingInvite was missing.

  • The server-side Clerk primitives are exported too — for host routes beyond the built-in /people/role. Beside createClerkUser/createClerkInvitation/canChangeRole, chapter exports the odla→Clerk write half: clerkGetUserByEmail, clerkGetUser, clerkListUsers (auto-paginated — never a silent 100-user cap), clerkSetRole, updateClerkUserMetadata, and updateClerkUserMetadataByEmail. An absent public_metadata.role reads as the lowest rung ("provisional"); normalized profile and applicationId reads come only from backend-only private metadata; and clerkSetRole merge-PATCHes only the public role, so it cannot clobber the private profile. All take an injectable fetch and the vault clerk_secret_key.

Verify from the types, not this file

At several releases a day, prose lags. Treat this README as intent and verify the real surface from dist/*.d.ts and by grepping the built bundle for route strings. One testing gotcha: Clerk session tokens expire in ~60s, so a script that mints a JWT then runs a batch of curls must re-mint per batch.

MIT © odla