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

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 and routing. Chapter supplies the application mechanics plus an optional, versioned membership-card renderer so a hub preview and follower site can render the same reviewed content exactly.

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. Durably claimed email (sendTemplated/isAlreadySent; ambiguous non-idempotent provider outcomes require evidence-based reconciliation rather than blind resend), 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 verified Stripe events and refund reconciliation as the payment authority, 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/member — Preact-only: SlotPicker, the date/timezone helpers, JoinIsland (form → payment → booking), MembersArea, GiftPurchases, GiftCodeRedeemer, 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.

Packaged workspaces are styled by Chapter, through @odla-ai/ui

Chapter owns the styling of every workspace it ships (Admission, Builder Library, People, Settings, and the rest) and does it only through @odla-ai/ui form components and the --ui-* tokens the active theme scope supplies. A packaged picker is always the @odla-ai/ui Select, a native select carrying the select class, so it takes the host's theme without any host CSS, keeps its accessible name through its Field label, and keeps native keyboard behaviour. A guard test in the package fails if a bare select tag reappears in a Chapter UI source.

The boundary runs the other way too. A host must not patch package-owned markup with its own CSS: if a packaged control looks browser-default, the theme layer is missing (see the banner above) or the package has a bug to report, never a selector to override. Any custom picker a host adds beside a packaged workspace should be the same @odla-ai/ui Select (with Field for its label) rather than a bare element, so host and package controls read as one surface.

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 },
  policy: {
    merchantDisclosureText: "Processed and billed by Example Merchant on behalf of Example Chapter.",
  },
  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.

Set policy.merchantDisclosureText to the exact plain-language identity the customer will see on the charge or receipt. Chapter carries that text through the public join config and renders it before the payment consent control in membership and gift checkout.

Join and member composition

The packaged journey (Chapter 0.59.0): inside JourneyFrame, every text control renders at 16px or more on a touch device, above a host's own input[type=text] rule, because iOS Safari zooms a page whose focused field is smaller and keeps that zoom through the payment step. The booking step keeps its button in view above the dates, inactive until a date and a time are both chosen, with a line beneath it reading copy.booking.pickDate ("Pick a date"), then pickTime ("Pick a time"), then nothing; the island starts the /api/schedule/slots read while the member pays (useJoinSlotsPreload), so the times are already on screen when payment confirms. The payment receipt renders after each step's body, so a booked member sees their confirmation first and the receipt beneath it.

For a centrally managed membership page, validate a bounded presentation document against the live, server-authoritative tiers, then render it with MembershipCards:

import {
  MEMBERSHIP_PAGE_RENDERER,
  membershipPageDocument,
} from "@odla-ai/chapter";
import { MembershipCards } from "@odla-ai/chapter/ui/member";
import { ThemeScope } from "@odla-ai/ui/components";

const membershipPage = membershipPageDocument({
  schemaVersion: 1,
  renderer: MEMBERSHIP_PAGE_RENDERER,
  tiers: [
    {
      tierId: "steward",
      variant: "supporting",
      badge: "Sustains the collective",
      benefits: ["Everything in **Founding Member**", "The annual retreat"],
      callout: {
        label: "A word of thanks",
        bodyMarkdown: "*You are the reason this collective can exist.*",
      },
      ctaLabel: "Fund the movement",
    },
    {
      tierId: "founding",
      variant: "featured",
      badge: "Most members start here",
      compareAtPriceCents: 100_000,
      billingLabel: "a year",
      capacity: { total: 100, label: "only" },
      priceNoteMarkdown: "Your founding rate is **held for as long as you stay**.",
      benefits: ["Live pitch nights", "A seat in diligence"],
      ctaLabel: "Join the collective",
    },
    {
      tierId: "associate",
      summaryMarkdown: "Open to everyone, anywhere",
      benefits: ["Founder pitch recordings", "The monthly member letter"],
      ctaLabel: "Join for free",
    },
  ],
}, { tiers: joinConfig.tiers });

<ThemeScope tokens={approvedChapterTokens}>
  <MembershipCards
    tiers={joinConfig.tiers}
    document={membershipPage}
    applicationPath="/apply"
  />
</ThemeScope>

The document controls tier order and bounded copy slots. It cannot author prices, free/paid state, tier identity, links, CSS, or HTML; those remain live Chapter authority. Unknown fields, missing/duplicate tiers, oversized content, and compare-at prices that do not exceed the charged price fail validation. Markdown is rendered by the safe renderer from @odla-ai/ui.

MembershipCards is token-native, including --ui-font-sans, --ui-font-display, and --ui-font-numeral as well as the semantic color, surface, border, radius, spacing, and shadow tokens. Its responsive layout and component CSS are versioned with the renderer. A CMS preview uses mode="preview"; the public page uses the default link mode. Both therefore share the same component, document, tier authority, and token scope.

The same renderer can also replace the placeholder chooser inside JoinIsland without reimplementing selection:

<JoinIsland
  config={joinConfig}
  renderTiers={({ tiers, selectedTierId, selectTier }) => (
    <MembershipCards
      tiers={tiers}
      document={membershipPage}
      mode="select"
      selectedTierId={selectedTierId}
      onSelectTier={selectTier}
    />
  )}
>
  <ApplicationFields />
</JoinIsland>

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,

Shared application and additional-member controls

JoinIsland owns application readiness. Its default Submit button and the disabled value passed to renderSubmit reflect required controls, native constraints, host-provided custom validity checks, conditional fields and recipient prefill. Use JoinSubmitButton in a custom submit slot to retain the packaged theme and disabled styling. submitDisabled remains an additional host gate.

Use AdditionalMemberFields on Apply and payment.additionalMember for the packaged editor. Supply copy, application field names and theme tokens; do not reimplement validation, save/error handling, or action spacing in the host. Declare the same conditional name/email requirements in the server application field contract. The quote remains authoritative for price and saved details.

const memberOptions = {
  copy: { label: "A membership for your mother, daughter, or sister" },
  names: { selected: "familySelected", name: "familyName", email: "familyEmail" },
};
const [member, setMember] = useState({ selected: false, name: "", email: "" });
<JoinIsland config={config} payment={{
  initialNamedSeat: additionalMemberSelection(member) ?? undefined,
  additionalMember: { ...memberOptions, onSaved: setMember },
  namedSeatLabel: "Additional family member",
}}>
  {/* Other site application fields */}
  <AdditionalMemberFields {...memberOptions} value={member} onChange={setMember} offer={selectedTier?.seatOffer} />
</JoinIsland>

Import these controls from @odla-ai/chapter/ui/member. The editor uses Save details for both included and paid members, a separate Cancel action, retryable errors, and disabled controls while saving. Checkout remains read-only until Edit details is chosen. Legacy renderNamedSeatEditor remains available for specialized composition; new hosts should use the packaged controls. JourneyFrame supplies responsive layout and uses --ui-accent, --ui-on-accent, --ui-text-muted and --ui-surface-2 for enabled and disabled states. The default checkout editor also uses these shared fields and readiness.

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.

Closed/refunded/declined applications return a scoped 409 with code: "application_closed"; loadJoinResume maps this to step: "closed". The island shows a terminal notice without another form, checkout, or booking. Unknown continuation states and mismatched application ids are rejected rather than defaulting to payment. Temporary configuration failures remain retryable.

Studio and automated previews can traverse that same state machine without creating an application, loading Stripe, charging a card, or booking a real calendar event. Supply deterministic simulation boundaries and keep the production config, tier selection, copy, render slots, and step transitions:

<JoinIsland
  config={joinConfig}
  simulation={{
    applicationId: "preview-application",
    payment: {
      lineItems: {
        standardCents: 100_000,
        discountCents: 10_000,
        dueTodayCents: 90_000,
      },
    },
    booking: {
      timezone: "America/Los_Angeles",
      slots: [{ startAt: Date.UTC(2026, 8, 3, 17) }],
    },
  }}
>
  <PreviewApplicationFields />
</JoinIsland>

Simulation is deliberately a UI/test boundary, not acceptance evidence for a live Chapter. A follower still has to prove its real application write, Stripe webhook, and calendar booking journey before production.

Authenticated member pages

A purchaser whose named-seat invitation is open (sent, not yet accepted, not held for their own approval) can forward it themselves: NamedSeatInvitationLink from @odla-ai/chapter/ui/member renders the same /join?seat=… link the invitation email carries, with "Copy invitation link" and "Copy a ready-to-send message" controls that name the recipient's email, which is the only address the link opens for. NamedSeatPurchase includes it; a host with its own seat card renders it under the recipient line and passes joinPath when its application page is not /join. namedSeatInvitationShareable, namedSeatInvitationUrl and namedSeatInvitationMessage are the pure pieces.

Enable the standard member catalog explicitly:

const chapter = defineChapter({
  // ...normal chapter config...
  memberContent: {
    localAuthoring: true,
    parent: {
      sourceId: "built-not-found",
      // default follower vault key: member_content_control_secret
    },
  },
});

This creates two deliberately separate authoring layers. The Parent delivers immutable signed revisions; the Chapter admin edits one local revision. The effective catalog merges them without permitting a Parent replay to replace a Chapter page. Each page declares a stable id/slug, navigation section/order, safe Markdown, and either all-members or an explicit set of tier ids. Tier sets are not treated as an ordinal ladder.

const parentDocument = {
  schemaVersion: 1,
  revision: 4,
  generatedAt: Date.now(),
  source: { type: "parent", appId: "built-not-found" },
  target: { chapterId: "example-chapter", environment: "dev" },
  pages: [{
    id: "steward-library",
    slug: "steward-library",
    title: "Steward library",
    navigation: { section: "Network", order: 20 },
    audience: { kind: "tiers", tierIds: ["steward"] },
    teaserMarkdown: "Advanced material for stewards.",
    bodyMarkdown: "The **private** operating library.",
    // Built Not Found delegates only this offer to the Chapter admin.
    upsell: { owner: "chapter" },
  }],
};

An upsell references only a live tier id and bounded copy. Authored content cannot set price, Stripe ids, checkout state, HTML, CSS, or arbitrary links. The Worker attaches the currently offerable tier name and price. A Parent page may own its offer (owner: "parent") or declare owner: "chapter"; the local document then uses upsellOverrides for that exact page. Chapter-authored pages always keep Chapter offer authority.

GET /api/member-content requires a signed-in member. Chapter resolves the member's tier from their local application and removes every unauthorized bodyMarkdown before serializing JSON. Locked metadata may include a teaser and resolved offer. Admins may preview every body through the same projection. Client-side hiding is not an authorization boundary.

Render the projection through the stock member area:

<MembersArea api={api} signOut={signOut} memberPages />

Or compose MemberContentArea directly and provide a host-owned upgrade URL or callback. The package does not pretend a second application is an upgrade:

<MemberContentArea
  api={api}
  upsellHref={(tierId) => approvedUpgradeRoute(tierId)}
/>

The standard Chapter admin keeps the top-level Dashboard/People/Settings grammar and adds a Member content tab under Settings. It labels inherited pages as read-only, shows source/access/offer ownership, and edits the complete local JSON contract with revision guards and actionable validation errors. Agents may use the same authenticated /api/admin/member-content GET/PUT contract. Page bodies remain out of CRM; CRM continues to own people and lifecycle state.

For central delivery, pair createMemberContentEnvelope(document) with deliverMemberContent from @odla-ai/chapter/worker. The dedicated signed route rejects wrong source/target/environment, stale or conflicting revisions, bad digests, undeclared tiers, and any Parent revision that would invalidate existing Chapter-owned content. The CRM edge and signup-control secret are never reused.

Agents can run the same contract through the supported CLI. tiers.json contains a JSON array of the live safe authority projection (id, name, priceCents, and active) and deliberately excludes provider Price ids:

odla-ai chapter member-content validate --file parent.json --tiers tiers.json --json
odla-ai chapter member-content compose --parent parent.json --chapter chapter.json --tiers tiers.json --json
odla-ai chapter member-content project --parent parent.json --chapter chapter.json --tiers tiers.json --tier community --json
odla-ai chapter member-content deliver --file parent.json --tiers tiers.json \
  --url https://chapter.example --from-env MEMBER_CONTENT_SECRET --json

Use --stdin instead of --from-env when the signing secret comes from a producer process. The value is never accepted as an argument. project is the server-disclosure proof and omits locked bodies; compose is a local operator authoring view and contains the supplied content.

Private Builder Library

Builder source material is deliberately separate from member publishing. Enable it on both the Built Not Found hub and each participating Chapter:

// Built Not Found
network: {
  targets: [
    { id: "oakland", url: "https://oakland.example", binding: "OAKLAND" },
    { id: "denver", url: "https://denver.example", binding: "DENVER" },
  ],
},
builderLibrary: {
  sources: [{ sourceId: "oakland" }, { sourceId: "denver" }],
},

// Oakland
network: {
  readers: [{ id: "built-not-found", fields: { person: ["name"] } }],
},
builderLibrary: {
  sources: [{ sourceId: "built-not-found" }],
},

Every resource has exactly one scope: { kind: "chapter", chapterId }, { kind: "built_not_found" }, or { kind: "global" }. There are no groups or audience arrays. A Chapter can author only its own scope. Built Not Found can author material for itself, every Chapter, or exactly one Chapter from its configured source list. A Chapter's admins and agents see its own published material plus global material, never a sibling Chapter or Built Not Found-only material. Built Not Found admins see every configured Chapter source and can promote a Chapter resource to a global copy; the server records the source Chapter, source resource/document revisions, actor, and time.

Resources are bounded documents/notes, HTTPS links/inspiration, or canonical Brand { bookId, assetId } references. Asset URLs, bytes, storage credentials, HTML, and executable content are not part of the contract. The standard admin adds a visibly private Builder Library tab separate from Member content. It is a compact CMS inventory with search and type/status filters, type-specific add/edit fields, previews, explicit Draft/Published/Archived state, and a visibility picker whose Chapter choices come only from checked-in network configuration. Saving a library item never creates or changes a member page.

An authenticated admin or site-building agent reads the bounded, published-only manifest from /api/admin/builder-library/manifest. On Built Not Found, pass a configured chapterId query parameter; on a Chapter, the server fixes the viewer to that Chapter. Every Builder source must already be the corresponding network.target (on Built Not Found) or network.reader (on a Chapter). Signed delivery reuses that Chapter network edge: the Chapter's existing network_share_secret is the same value Built Not Found already stores as network_share_<chapter_id>. Builder Library declares and provisions no secret of its own.

odla-ai chapter builder-library validate --file oakland-library.json --source-type chapter --source oakland --json
odla-ai chapter builder-library manifest --file oakland-library.json --chapter oakland --json
odla-ai chapter builder-library deliver --file oakland-library.json \
  --url https://builtnotfoundcapital.com --from-env NETWORK_SHARE_SECRET --json

The existing network share secret is accepted only from a named environment variable or stdin; it is never copied into Builder Library config or argv. For a Built Not Found local document, add --target oakland --environment dev; the CLI signs only its global resources for that Chapter, never BNF-only items.

Hub-authoritative live signup control

When a hub must control the live membership page as well as preview it, use the dedicated signup-control contract. Do not widen the CRM network route: CRM record sharing and operational signup authority are different capabilities. The follower opts into one exact source and Stripe mode:

const chapter = defineChapter({
  // ...normal chapter config and local bootstrap values...
  signupControl: {
    sourceId: "built-not-found",
    stripeMode: "test",
    // default follower vault key: signup_control_secret
  },
});

The hub builds a complete SignupControlDocument, calls createSignupControlEnvelope(document), and sends it with deliverSignupControl from @odla-ai/chapter/worker. The same strong random secret is vaulted separately on each side; it is never a Clerk token, ODLA key, or CRM network secret.

For multiple authoritative runtimes sharing a data environment, set signupControl.runtimeSecrets to an explicit map such as { cory: "signup_control_cory", tori: "signup_control_tori" }. Each value names a different follower vault key; paired hub credentials remain separate too. Unlisted runtimes and untargeted legacy deliveries then fail closed. Include deliveryTarget: { appId, environment, runtime } beside the envelope's document and digest. This transport identity is signed but excluded from the document digest, so both runtimes accept or replay the same reviewed state. The receipt attests the receiving runtime; the sender validates exact target, revision, digest, status, and timestamp. Delivery does not follow redirects and bounds receipt reads to 8 KiB and ten seconds.

Provision the additive signupControlHeads namespace before deploying this receiver. Its transaction guard serializes concurrent first publications and stale work across runtimes. The hub must likewise allocate revisions atomically and freeze the document in the same transaction as its per-runtime outbox rows. No process should regenerate an old revision from the current catalog on retry.

The document carries the source, exact chapter/environment/Stripe-mode target, monotonic revision, ordered and available tiers, immutable Stripe Price ids, the membership-page renderer document, and explicitly delegated application, policy, scheduling, and theme sections. Theme tokens include typography as allowlisted --ui-* CSS custom properties such as --ui-font-display and --ui-font-sans. Chapter rejects non---ui-* names, non-finite values, oversized token sets, control characters, style-tag breakout, declaration breakout, comments, imports, expressions, and URL-bearing values. Application aliases such as --teal or --deepocean remain host-owned CSS.

The packaged follower receiver validates the signed request timestamp, sender, target, environment, Stripe mode, schema, and SHA-256 digest. It rejects a stale revision and rejects the same revision with different content; the same revision and digest is an idempotent replay. Acceptance transactionally stores immutable history, switches the active revision, and materializes the group/tier rows that join-config, application tier validation, and checkout already read. Public GET /api/join-config returns the active revision/digest, renderer document, application contract, and theme. Applicant requests therefore use local last-known-good state and never depend on the hub being reachable.

A successful empty tier query retains legacy single-tier compatibility. Database failures, malformed responses, and missing stored prices fail closed; they never invent a free membership. Retiring all stored tiers leaves no offers instead of reviving the group's legacy price. Public Worker routes return 503 with tiers_unavailable for this failure rather than charging a fallback.

One field contract for follower forms and CMS management

Chapter owns field identity and storage authority; a hub such as Built Not Found owns the editor. Declare the follower's maximum application field set in defineChapter({ application: { required, optional, conditions, maxLen } }). A signed signup-control revision may select, reorder, and change requiredness only within that declared set. It cannot invent a field, change its value type, or expose an operational application attribute.

chapterFlowFieldContract(chapter, "join") and chapterFlowFieldContract(chapter, "formation") return the same versioned, ordered shape: stable field id, label, CRM value type, requiredness, order, length cap, enum options, and conditions where the flow supports them. GET /api/join-config exposes the effective fieldContract after active signup control is applied. GET /api/formation/config exposes the same fieldContract and retains its legacy fields array as a compatibility alias. Use that document for a CMS preview or host renderer; submit to the packaged route so the server enforces the same effective selection.

Email identity is literal. normalizeChapterEmail() trims and lowercases only; it never applies Gmail/provider alias rules. Consequently [email protected] and [email protected] are distinct application, admission, purchase, and gift-beneficiary identities even if a mailbox provider delivers both to the same inbox. Email fields advertise emailIdentity: "literal" in the flow contract so editors and test fixtures do not collapse them.

Materialization addresses provisioned groups and tiers by their unique public id attributes, not by assuming those values are odla-db entity ids. A failed materialization returns a bounded JSON materialization_failed rejection and the transaction leaves the prior active revision unchanged.

The first delivery is empty-history safe: Chapter checks a bounded aggregate count before asking odla-db to order revision history. A freshly provisioned follower therefore acknowledges revision 1 immediately instead of depending on an ordered query over an empty result set.

Render active tokens before first paint

Put Chapter's marker after the local bootstrap theme and before any application boot script in the HTML head:

<link rel="stylesheet" href="/bootstrap-theme.css">
<!--odla:signup-control-theme-->
<script type="module" src="/app.js"></script>

A host route can transform the static asset with the follower-local active row:

import { chapterWorker, withActiveSignupControlTheme } from "@odla-ai/chapter/worker";
import { chapter } from "./chapter.config.mjs";

export default chapterWorker({
  chapter,
  routes: [async (request, url, env, context) => {
    if (request.method !== "GET" || url.pathname !== "/apply") return null;
    const asset = await env.ASSETS.fetch(new URL("/index.html", url));
    return withActiveSignupControlTheme(asset, {
      db: context.makeDb(env),
      chapterId: chapter.id,
      environment: env.ODLA_ENV,
      // The site's existing deterministic fallback when no revision is active.
      bootstrapTokens: { "--ui-accent": "#096ea8" },
    });
  }],
});

withActiveSignupControlTheme reads only the follower database, revalidates the stored document and its canonical digest, and injects one bounded style block with matching revision/digest metadata. It never calls the controlling hub. A missing active row emits the documented bootstrap state; a failed local read or corrupt active row fails closed instead of looking like absence. The response defaults to Cache-Control: no-store, carries digest/revision headers, and gets an ETag over the complete transformed HTML. This prevents a pre-activation page from surviving activation. A host-owned cache may instead call renderActiveSignupControlHtml() and include result.theme.metadata.cacheKey in its complete page cache key.

For an SSR or static-HTML transform, use the same helper on the generated string:

import { renderActiveSignupControlHtml } from "@odla-ai/chapter/worker";

const result = await renderActiveSignupControlHtml(generatedHtml, {
  db, chapterId: chapter.id, environment,
  bootstrapTokens: { "--ui-accent": "#096ea8" },
});
await writeHtml(result.html); // cache with result.theme.metadata.cacheKey

After GET /api/join-config, hydrate the existing style element rather than writing a second theme system. Matching revision/digest metadata produces no DOM write and therefore no visual token change:

import {
  SIGNUP_CONTROL_THEME_STYLE_ID,
  hydrateSignupControlTheme,
} from "@odla-ai/chapter";

const style = document.getElementById(SIGNUP_CONTROL_THEME_STYLE_ID);
if (style instanceof HTMLStyleElement) {
  hydrateSignupControlTheme(style, joinConfig.signupControl ?? null, {
    bootstrapTokens: { "--ui-accent": "#096ea8" },
  });
}

The host still decides which routes use managed theming and owns any legacy token aliases. Keep the marker after bootstrap declarations so the active --ui-* values win in the initial parsed document.

GiftPurchases is the authenticated purchaser surface: it renders configured offers, checkout, one-time code recovery, and the buyer-safe pending/redeemed/expired/revoked ledger. MembersArea composes this surface and omits it when there are neither current offers nor prior purchases. MemberGifts remains a deprecated compatibility name for the same purchaser-only component; it no longer renders a recipient claim control.

GiftCodeRedeemer is the separate recipient control. It requires the exact payment-stage applicationId plus an authenticated ApiFn, posts trimmed codes to /api/gifts/redeem, and keeps its input, action, loading lock, and reserved live status region aligned with the shared @odla-ai/ui inline-action-field contract. Pass that adapter as JoinIsland.giftClaimApi to compose the claim before card setup. The Worker accepts only a signed-in user whose email/account, application, unpaid state, and tier match the pending claim; a successful claim advances through the canonical join continuation without starting Stripe. Code entry uses one-time-code autocomplete, disables automatic capitalization and correction, and retains the submitted code after an error so normal card payment remains available.

The hub owns a durable outbox: create/verify immutable Stripe Prices first, persist the desired revision, retry signed delivery, and mark it active only after the exact revision/digest receipt. Version history is for audit and rollback, not a second manual publish gate. A failed delivery leaves the follower's prior active revision untouched. Production remains behind the normal deployment approval checkpoint.

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"] },
      // Independent consent to compare the chapter's commercial projection.
      commercialParity: true,
    }],
  },
});

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 in the same versioned fieldContract shape as the join flow.
  • 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.

Shared-Stripe commercial shadow

A hub may opt into a read-only network commercial projection when multiple chapters intentionally bill through one Stripe account. Every accepted source is an exact partition, including Stripe test/live mode:

networkCommercialReadModel: {
  mode: "shadow",
  sources: [{
    stripeMode: "test",
    appId: "example-chapter",
    chapterId: "example-chapter",
    environment: "dev",
    dataEnvironment: "dev",
    runtime: "cory",
  }],
},

The dedicated /api/network-commercial/stripe-webhook verifies Stripe's signature with network_commercial_stripe_webhook_secret before writing an append-only, PII-free event fact. The projector is rebuildable and deterministic: late metadata-less events may inherit a partition only through an unambiguous provider customer/subscription/invoice/charge reference. Wrong, missing, ambiguous, unsupported, and colliding events remain explained exceptions.

GET /api/admin/network-commercial-read-model is super-admin-only and exposes current customer, subscription, invoice, refund, and currency-total facts for a BNF operator surface. POST with { command: "rebuild", mutationId } rebuilds from the local event log without calling or mutating Stripe. POST with { command: "reconcile-stripe" } performs bounded, GET-only current-object reads through the hub's vaulted stripe_secret_key and reports missing, unexpected, mismatched, truncated, and totals exceptions without changing the event log or projection. Objects outside the configured exact partitions are counted as ignored rather than adopted. This shadow never authorizes charges, refunds, subscription changes, or Chapter lifecycle writes; removing it changes no customer-facing behavior. The BNF frontend may be down while the webhook continues to ingest. Registering a Stripe endpoint and enabling production remain separate reviewed operations.

A second delivery of a retained event id is a replay when its digest matches, or when its canonical fact (id, type, created, and the allowlisted data.object fields) equals the retained fact however the delivery envelope changed. A canonical replay also resolves an open collision for that event, recording a paymentRecoveryReviews row with the system as actor, unless the collision recorded a different fact. Only a delivery whose fact differs registers a collision, and it stays active through rebuilds until an operator decides: POST with { command: "resolve", eventId, mutationId, reason } records the actor and reason, dismisses the colliding delivery so the same one replays quietly, and keeps the retained fact as the evidence of record; it refuses without a valid mutationId and a reason. POST with { command: "recover-event", eventId } instead trusts an independent read of the provider Event and rewrites the retained digest when every fact matches.

/api/admin/payment-recovery is the drain on both sides. A chapter lists and retries its quarantined Stripe receipts; a hub that owns the read model lists every active commercial exception (count plus provider ids, no PII) and retries one through the same ingest the signed webhook uses after an independent provider read, with a review row per retry.

Both Workers raise an error-level operator alert when a receipt enters quarantine or a commercial exception becomes active, carrying the current count and provider event ids only. Pass recordError from @odla-ai/o11y as chapterWorker({ chapter, recordError }) to deliver those alerts to o11y; the default is one structured console.error line per alert.

POST with { command: "reconcile-chapters" } is a separate, two-sided opt-in comparison. A hub target and the matching follower reader must both set commercialParity: true; neither record browsing nor Stripe shadow mode grants it implicitly. The hub signs a GET /api/network/commercial-parity request with the edge's network_share_* secret. The chapter returns only bounded status, cancellation, counts, completeness, and domain-separated HMAC-SHA-256 digests of application, Stripe customer, and Stripe subscription identifiers. Name, email, raw identifiers, and arbitrary application fields never cross the edge. The hub compares those digests with its exact Stripe partition and reports chapter-only, shadow-only, cancellation, count, unavailable, invalid, and truncation exceptions without persisting or changing either side. Chapter application status and Stripe subscription status remain distinct vocabularies and are shown side by side rather than falsely compared for equality.

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"],
      // Separate again: name the exact profile fields and record types the
      // parent may mutate. Omission keeps the edge read-only.
      editableFields: { person: ["name", "email", "phone", "linkedin"] },
      stageTransitions: ["person"],
      admissionGrants: true,
      // Independent disclosure consent. This may be the reader's only ability.
      commercialParity: true,
    }],
  },
});

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, optimistic revision, and explicitly delegated edit metadata 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. When both halves of an edge opt in, PATCH /api/admin/network/records accepts one bounded field patch or lifecycle destination with the follower-authored expectedRevision, current stage, and a stable mutation id. The follower revalidates its CRM schema, identity/email rules, pipeline gates, and atomic revision guard; it remains source of truth and records the leader peer plus initiating admin in the CRM activity audit. A retry with the same input is idempotent, while stale state is 409, an undelegated operation is 403, invalid fields or transitions are 422, and ordinary browse-only edges remain read-only.

Admission decisions use a separate default-deny lane. With admissionGrants: true on both halves of an edge, the leader's POST /api/admin/network/admissions resolves the person's current email from its CRM, persists the complete desired target document, and delivers it through the configured service binding or signed URL. Delivery state is durable as saved, pending, acknowledged, retrying, or rejected; retry and revoke routes publish the same or a higher desired revision. A follower may declare an admission-only reader without fields, which does not make CRM records browsable or editable.

The Network detail view also has two deliberate note 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"],
      editableFields: { person: ["name", "email", "phone", "linkedin"] },
      stageTransitions: ["person"],
      admissionGrants: true,
    }],
  },
});

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, editableFields?, stageTransitions?, admissionGrants? };
  • 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.

Pair the edge by vaulting one generated value as network_share_secret in the follower and as network_share_example_chapter in the leader (or the target's secretName), never printed. The integration descriptor declares both slots, so provisioning tooling can hold them against the vault, and until both hold a value every leader-side surface reports the edge as code: "edge_unpaired" with a hint naming the CLI pairing command (chapter edge pair, which writes both slots from one generated value and ships with the next @odla-ai/cli release). 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. Pair the edge: vault one generated value as network_share_secret in the follower and under the target's resolved secretName in the leader (the CLI's chapter edge pair command does both writes from the next @odla-ai/cli release on). The leader rollup reports edge_unpaired until both slots hold a value.
  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 operati