Ai
Platform servicesPreviewTome AI layer — BYOK per-member encrypted credential store + Anthropic adapter + runFeature narration runtime.
Get a free registry token from your credentials page. Every install from our registry needs one, free packages included.
Add the registry and your token to the
.npmrcat the root of your project, with your token in place ofYOUR_TOKEN:@wabbit:registry=https://npm.wabbit.com/ //npm.wabbit.com/:_authToken=YOUR_TOKENThen install:
npm install @wabbit/tome-ai
Overview
@wabbit/tome-ai
BYOK (bring-your-own-key) AI narration for the Tome platform: each member stores an encrypted provider API key (AES-256-GCM, per-member), an Anthropic adapter resolves it at runtime, and runFeature('rpg.narrate') drives narration against confirmed @wabbit/tome-gamification events. domain layer per root ARCHITECTURE.md — @wabbit/tome-core is an optional peer.
This is a deliberately scoped subset of the 2026-04-10 tome-ai spec (Amendment A1 — "thin-cut narration only"). The full capability surface the spec describes — streaming, tool use, structured output, vision, multi-provider routing — is deferred. Trigger to build it out: once the BYOK + narration loop has run in production at dogfood scale (@wabbit/tome-rpg's narration feature is the current real consumer, wired in wabbit-site-core).
Install
pnpm add @wabbit/tome-ai| Peer | Range | Notes | |---|---|---| | payload | >=3.67.0 | required | | @wabbit/tome-core | >=1.0.0 <2.0.0 | optional — the layer boots without it; you just lose the admin-nav/layer-registry manifest entry |
@anthropic-ai/sdk and server-only are regular dependencies.
Environment
AI_CREDENTIAL_KEY — set this in production. The encryption key is derived via crypto.hkdfSync('sha256', ...) from this value (not used raw), with a fixed non-secret domain-separation salt. If unset, getEncryptionKey() falls back to PAYLOAD_SECRET and logs a one-time console warning telling you to set a dedicated key; if neither is set, key derivation throws at call time rather than silently encrypting with an empty key. Changing whichever secret is in use makes every stored credential undecryptable (GCM auth fails on read) — members must re-enter their keys.
AI_MODEL_FAST / AI_MODEL_BALANCED / AI_MODEL_DEEP — optional overrides for AnthropicAdapter.modelTiers (defaults claude-haiku-4-5 / claude-sonnet-4-6 / claude-opus-4-8). runFeature('rpg.narrate', …) uses the balanced tier with maxTokens: 1024.
60-second quickstart
// payload.config.ts
import { createAiLayer } from '@wabbit/tome-ai'
// Adds the `ai-credentials` collection. Its required `owner` field relates to
// `members` by default — pass { memberSlug: 'users' } if your auth collection is named differently.
const { collections } = createAiLayer()
// collections: [...collections] into your Payload configcreateAiLayer is the canonical layer entry; defineAiLayer is a deprecated pure alias. The returned hooks.encryptApiKeyHook is already attached to the collection's beforeChange — it is returned for reference, not for you to register again.
Access: a signed-in non-admin can create credentials, and read, update or delete only their own. The collection pins owner before validation: a non-admin's new credential is always owned by req.user (any owner they send is replaced, and it may be omitted), and a non-admin cannot change owner on update. Admins, and Local API calls with no req.user, set owner freely — which is how the server-action example below stores a key for memberId.
// Server action: store a member's key. Write the plaintext to `apiKey`; the
// collection's beforeChange hook (encryptApiKeyHook) moves it into the
// AES-256-GCM `encrypted` group and strips `apiKey`, so the plaintext is never persisted.
await payload.create({
collection: 'ai-credentials',
data: { owner: memberId, provider: 'anthropic', label: 'My Claude key', apiKey: pastedKey },
})// Server-side narration call
import { runFeature, NoCredentialError } from '@wabbit/tome-ai/server'
try {
const { narration } = await runFeature(
'rpg.narrate',
{
persona: 'larry', // 'larry' | 'jasper'
mode: 'daily', // 'daily' | 'weekly-recap'
confirmedEvents: [{ amount: 10, reason: 'lesson-complete', at: new Date().toISOString() }],
streakState: null,
questState: null,
},
{ payload, callerMemberId: String(memberId) },
)
} catch (err) {
if (err instanceof NoCredentialError) {
// the member has not stored a key yet — prompt them to add one
}
throw err
}runFeature returns { narration } only today — NarrateOutput.proposedQuests is typed but never populated, and the system prompt is a neutral placeholder a product must replace with its own voice before production use.
Public API
| Export | Subpath | Description | |---|---|---| | createAiCredentialsCollection(config?), AiCredentialsCollection | root | Payload collection factory/default for per-member encrypted credentials | | encryptApiKeyHook | root | The collection's beforeChange hook — AES-256-GCM encrypts the pasted key before it's written; safe to export from the main barrel (no server-only import — it's a Payload hook function, not a bundling concern) | | createAiLayer(config?) | root | Layer factory — builds the collection(s), registers with layerRegistry (lazy require() in try/catch, tolerant of tome-core absence). Returns { collections, hooks } — the named-object bundle shape (this layer contributes both a collection AND a hook, unlike the bare-array createOrgLayer/createCrmLayer shape). | | ProviderAdapter, PointsEventSummary, ProposedQuest, NarrateInput, NarrateOutput, FeatureContext | root | Type surface | | NoCredentialError | root | Thrown when runFeature can't resolve a decrypted key for the member — safe to export from the main barrel | | runFeature(featureId, input, context) | ./server | The narration runtime entry point — featureId is 'rpg.narrate' (the only feature), input is a NarrateInput, context is { payload, callerMemberId }; resolves the member's credential, calls the adapter, returns NarrateOutput | | AnthropicAdapter | ./server | The (currently sole) ProviderAdapter implementation — modelTiers, validateCredential(apiKey) (a cheap models.list() probe you can call before saving a key), runCompletion(...); every capabilities flag is false | | getDecryptedKey(payload, ownerId, opts?) | ./server | Decrypts and returns the member's stored API key, or null when they have none. Reads the first matching ai-credentials row with access control bypassed; opts.slug targets a renamed collection | | NoCredentialError | ./server | Re-exported here too, for callers who only import ./server |
Deprecated alias (pure rename, removal at this package's next major): defineAiLayer → createAiLayer.
Server / client posture
No .tsx files in this package at all — it's a Payload/Node backend layer with zero React. The split that matters is server-only vs main-barrel-safe, not client-vs-server in the React sense:
- Root barrel exports the collection factory, types, and
NoCredentialError— all safe to import anywhere, includingpayload.config.ts's top-level evaluation. - `./server` is guarded with
import 'server-only'at the top of its barrel —runFeature,AnthropicAdapter, andgetDecryptedKeyall touch the decrypted credential or call the Anthropic SDK, so this subpath must never end up in a client bundle. The root barrel's own trailing comment states the rule directly: "Do NOT export runFeature, AnthropicAdapter, getDecryptedKey from main barrel — server-only."
Testing
pnpm --filter @wabbit/tome-ai test runs the Vitest suite. Set AI_CREDENTIAL_KEY (any string) in the test environment so encryption does not fall back to PAYLOAD_SECRET or throw, and pass a stub payload with a find method to getDecryptedKey / runFeature rather than booting Payload. ./server imports server-only, which throws outside a React Server environment unless your test runner aliases it to an empty module.
Extending
A second ProviderAdapter (OpenAI, etc.) implements the ProviderAdapter type from ./types and is selected the same way AnthropicAdapter is today — this is the concrete first step toward the deferred multi-provider capability the full spec describes. New featureIds follow runFeature.ts's existing 'rpg.narrate' case as the template for context shape + output validation.
Design history
- Original design scoped this layer thin deliberately: one collection, one adapter, one feature (
rpg.narrate) — multi-provider and multi-feature support are deferred capabilities, not omissions, with the secondProviderAdapterdescribed under Extending above as the trigger for widening scope. docs/claude-gotchas.md→ Module / Exports Contracts section — "registerLayer MUST use lazyrequire()in try/catch, never static import" governscreateAiLayer's registration call
Exports
@wabbit/tome-ai@wabbit/tome-ai/server
Changelog
775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
- 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
3dd42b8: A signed-in non-admin can no longer create or reassign an AI credential under another member's `owner`. The `ai-credentials` collection now pins `owner` in a `beforeValidate` hook: a non-admin's create is always owned by `req.user` (an omitted `owner` is filled in), and a non-admin update cannot change `owner`. Admins and Local API calls with no `req.user` are unaffected. Read, update and delete stay owner-or-admin.
- 3dd42b8: A signed-in non-admin can no longer create or reassign an AI credential under another member's `owner`. The `ai-credentials` collection now pins `owner` in a `beforeValidate` hook: a non-admin's create is always owned by `req.user` (an omitted `owner` is filled in), and a non-admin update cannot change `owner`. Admins and Local API calls with no `req.user` are unaffected. Read, update and delete stay owner-or-admin.
0836ef5: dist now raw-Node loadable: relative specifiers get explicit extensions post-build. `build` gains `&& node ../../scripts/fix-dist-extensions.mjs --strict` as its last step, joining the 13 packages that already ran it. tsup builds `bundle: false` and emits relative specifiers exactly as the TypeScript source wrote them — extensionless — which bundlers resolve and raw Node does not (ESM `ERR_MODULE_NOT_FOUND`; CJS worse, `require('./x')` finds the ESM `.js` twin and Node 22+ `require(esm)` then dies on that file's own extensionless import). Every consumer outside a bundler hit this: the payload CLI under plain node, `generate:types`, `generate:importmap`, ops scripts, codegen tools. No source changes, no API changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable. Two supporting changes made the wiring possible, both in repo scripts rather than package source. `fix-dist-extensions.mjs` now skips bundler-asset specifiers (`.css`, `.module.css`, `.scss`, fonts, images, shaders) by explicit extension allowlist instead of reporting them as unresolvable — that single gap is why the 13 prior adopters were exactly the 13 packages that ship no CSS, since `--strict` exited 1 on any package with a relative stylesheet import. Dotted MODULE names (`./config.meta`, `./x.variants`, `./y.demo`) are deliberately NOT treated as assets and still get `.js`/`.cjs` appended. `assert-node-loadable.mjs` gained the matching carve-outs so the new repo-wide CI gate reports real defects only: a resolution failure whose path lands under `node_modules` is a peer SKIP (next@15 has no exports map, so `next/image` fails as an absolute path), and a bundler-asset load failure is an environmental SKIP (CJS surfaces it as `SyntaxError: Unexpected token '.'` raised from inside the stylesheet). Verified before/after on four packages built one at a time: print 8 FAIL → 0, readout 22 FAIL → 0, ai 3 FAIL → 0, gamification 2 FAIL → 0 (its failure was the other signature — a `directory import` missing `/index`). cop was already clean on a fresh build, so the audit's "27 of 46 fail" figure includes at least one package whose local dist was merely stale.
- 0836ef5: dist now raw-Node loadable: relative specifiers get explicit extensions post-build. `build` gains `&& node ../../scripts/fix-dist-extensions.mjs --strict` as its last step, joining the 13 packages that already ran it. tsup builds `bundle: false` and emits relative specifiers exactly as the TypeScript source wrote them — extensionless — which bundlers resolve and raw Node does not (ESM `ERR_MODULE_NOT_FOUND`; CJS worse, `require('./x')` finds the ESM `.js` twin and Node 22+ `require(esm)` then dies on that file's own extensionless import). Every consumer outside a bundler hit this: the payload CLI under plain node, `generate:types`, `generate:importmap`, ops scripts, codegen tools. No source changes, no API changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable. Two supporting changes made the wiring possible, both in repo scripts rather than package source. `fix-dist-extensions.mjs` now skips bundler-asset specifiers (`.css`, `.module.css`, `.scss`, fonts, images, shaders) by explicit extension allowlist instead of reporting them as unresolvable — that single gap is why the 13 prior adopters were exactly the 13 packages that ship no CSS, since `--strict` exited 1 on any package with a relative stylesheet import. Dotted MODULE names (`./config.meta`, `./x.variants`, `./y.demo`) are deliberately NOT treated as assets and still get `.js`/`.cjs` appended. `assert-node-loadable.mjs` gained the matching carve-outs so the new repo-wide CI gate reports real defects only: a resolution failure whose path lands under `node_modules` is a peer SKIP (next@15 has no exports map, so `next/image` fails as an absolute path), and a bundler-asset load failure is an environmental SKIP (CJS surfaces it as `SyntaxError: Unexpected token '.'` raised from inside the stylesheet). Verified before/after on four packages built one at a time: print 8 FAIL → 0, readout 22 FAIL → 0, ai 3 FAIL → 0, gamification 2 FAIL → 0 (its failure was the other signature — a `directory import` missing `/index`). cop was already clean on a fresh build, so the audit's "27 of 46 fail" figure includes at least one package whose local dist was merely stale.
- b01ca1f: Pin each layer's registered version to `package.json` instead of a hand-typed literal. `registerLayer(name, { version })` is the contract a consumer reads back through `hasLayer`/`getLayer` to gate on a layer's capability. Eight packages passed a literal that nobody compared to the manifest, so an up-to-date install advertised an old contract and every gate keyed on it failed **silently** — nothing throws when a version string is stale. | Package | Registered | Actual | | --------------------------- | ------------------------------- | ------ | | `@wabbit/tome-rpg` | `'0.1.2'` | 0.2.2 | | `@wabbit/tome-gamification` | `'0.1.0'` | 0.3.1 | | `@wabbit/tome-crm` | `'0.3.0'` | 0.5.0 | | `@wabbit/tome-ai` | `'0.1.0'` | 0.4.0 | | `@wabbit/tome-forms` | `TOME_FORMS_VERSION = '0.1.0'` | 0.3.2 | | `@wabbit/tome-intake` | `TOME_INTAKE_VERSION = '0.1.0'` | 0.3.1 | | `@wabbit/tome-marketing` | `'0.1.0'` | 0.4.0 | | `@wabbit/tome-chrome` | `'0.6.0'` | 0.8.5 | Each package now carries a leaf `src/version.ts` exporting `<NAME>_LAYER_VERSION`, read by its `registerLayer` call — the shape nine sibling packages (accounts, catalog, crowdfund, deals, economy, fulfillment, ledger, lms, org, workflow) already used and stayed accurate with. Forms' and intake's module-local `TOME_*_VERSION` consts move into that module: a _named_ constant was never the guarantee, a _pinned_ one is. The forcing function ships with the fix. `pnpm assert:layer-version` (new, wired into `platform-discipline.yml` pre-build) parses every `registerLayer` call in the repo, resolves its `version` argument through literals and consts, and fails on any disagreement with the manifest — so this cannot recur in a package that never gets around to writing the test. Seven of these eight were found by the 2026-09-01 sale-readiness audit; chrome was found by the assert itself on its first run. crm, forms, intake, marketing and rpg gained their first test suite in the process (`tests/layer-version.test.ts`) and were removed from the `assert:test-floor` starting-debt allowlist. No runtime behavior changes for a consumer already on a current install — the version a layer reports simply becomes true.
- 73081e6: Manifest metadata: `homepage`, `bugs`, `engines`. All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change. - `homepage` deep-links to that package README on GitHub (`.../tree/main/packages/<dir>#readme`). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want. - `bugs.url` points at the repo issue tracker, so a paying customer has a place to report a defect that is not email. - `engines.node` is `>=22`, matching the root `engines` and `.nvmrc` set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for `node --test`, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version. The forcing function ships with the change: `scripts/assert-manifest-metadata.mjs` (root `pnpm assert:manifest-metadata`, wired into `platform-discipline.yml` beside `assert:license-metadata`) fails when any publishable manifest lacks `description`, `repository.directory` matching its own folder, `homepage`, `bugs`, `engines.node` equal to the repo floor, `license`, `files` or `sideEffects`. It reported 138 violations before this change and 0 after.
68465b3: Role checks now understand a `roles` RELATIONSHIP, not just flat strings — unblocking admin gates that were silently shut. Three packages read `req.user.roles` by collecting only entries where `typeof entry === 'string'`, then comparing them to literal tier names (`'admin'`, `'instructor'`, …). On a site whose roles are a relationship to a Roles collection, that read produced `[]` and **every** tier check returned false. In tome-lms that closed `enrollmentCreate`, so a site's own super admin had no "Create" button on Course Enrollments; in tome-gamification it closed the Points/Badge/Achievement write gates; in tome-ai it scoped an admin to only their own credentials. The failure is silent — an access denial renders as a missing button, not an error. Two things made it worse than a simple shape mismatch: - **Payload binds `req.user` at `collection.auth.depth`, which defaults to `0`**, so a relationship arrives as raw ID strings. A site that also installs a custom auth strategy may populate it deeper — meaning the SAME deployment presents different shapes on different login paths. Widening the synchronous read alone would have fixed one path and left the other silently broken. - **`super-admin` matched nothing.** The tier lists hold literal role names, and `super-admin` is not one of them, so the highest-privilege role failed every check. Fixed in tome-lms and tome-gamification: - `readRoles` accepts flat names, populated Role docs (`{slug}`), the `_populatedRoles` enricher shape, and a flat singular `role` field. - `super-admin` now satisfies every tier, matching the platform-wide implicit `'*'` grant. - New `resolveRoleSlugs(req)` / `hasAnyRoleAsync` / `isAdminAsync` / `isDirectorAsync` / `isInstructorRoleAsync` / `isMaintainerRoleAsync` hydrate unresolved IDs through `req.payload`, memoized on `req.context` so a request running many access checks fetches at most once. Hydration never throws: a flat-name site keeps its synchronous result, so this is a strict widening for every shape. - Every collection access gate in both packages now uses the async resolvers. The synchronous helpers remain exported unchanged for hook call sites that already hold a populated user. Fixed in tome-ai: `AiCredentials`' admin check accepts populated Role docs and `_populatedRoles`, and recognises the canonical `super-admin` slug (it previously matched only camelCase `superAdmin`). It stays synchronous by design — a field-level credential gate is the wrong place for a per-check DB round-trip. No behaviour change for sites already using flat role strings: every previously-passing check still passes. Also pays the test-floor debt for all three packages: each gains its first suite — 35 cases covering every user shape, the super-admin rule, hydration, single-fetch memoization, failure tolerance and anonymous denial — and is removed from the `assert-test-floor` allowlist.
- 68465b3: Role checks now understand a `roles` RELATIONSHIP, not just flat strings — unblocking admin gates that were silently shut. Three packages read `req.user.roles` by collecting only entries where `typeof entry === 'string'`, then comparing them to literal tier names (`'admin'`, `'instructor'`, …). On a site whose roles are a relationship to a Roles collection, that read produced `[]` and **every** tier check returned false. In tome-lms that closed `enrollmentCreate`, so a site's own super admin had no "Create" button on Course Enrollments; in tome-gamification it closed the Points/Badge/Achievement write gates; in tome-ai it scoped an admin to only their own credentials. The failure is silent — an access denial renders as a missing button, not an error. Two things made it worse than a simple shape mismatch: - **Payload binds `req.user` at `collection.auth.depth`, which defaults to `0`**, so a relationship arrives as raw ID strings. A site that also installs a custom auth strategy may populate it deeper — meaning the SAME deployment presents different shapes on different login paths. Widening the synchronous read alone would have fixed one path and left the other silently broken. - **`super-admin` matched nothing.** The tier lists hold literal role names, and `super-admin` is not one of them, so the highest-privilege role failed every check. Fixed in tome-lms and tome-gamification: - `readRoles` accepts flat names, populated Role docs (`{slug}`), the `_populatedRoles` enricher shape, and a flat singular `role` field. - `super-admin` now satisfies every tier, matching the platform-wide implicit `'*'` grant. - New `resolveRoleSlugs(req)` / `hasAnyRoleAsync` / `isAdminAsync` / `isDirectorAsync` / `isInstructorRoleAsync` / `isMaintainerRoleAsync` hydrate unresolved IDs through `req.payload`, memoized on `req.context` so a request running many access checks fetches at most once. Hydration never throws: a flat-name site keeps its synchronous result, so this is a strict widening for every shape. - Every collection access gate in both packages now uses the async resolvers. The synchronous helpers remain exported unchanged for hook call sites that already hold a populated user. Fixed in tome-ai: `AiCredentials`' admin check accepts populated Role docs and `_populatedRoles`, and recognises the canonical `super-admin` slug (it previously matched only camelCase `superAdmin`). It stays synchronous by design — a field-level credential gate is the wrong place for a per-check DB round-trip. No behaviour change for sites already using flat role strings: every previously-passing check still passes. Also pays the test-floor debt for all three packages: each gains its first suite — 35 cases covering every user shape, the super-admin rule, hydration, single-fetch memoization, failure tolerance and anonymous denial — and is removed from the `assert-test-floor` allowlist.
6bc419c: Naming convergence (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.
- 6bc419c: Naming convergence (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.
- 36e537a: Every package now declares an explicit `sideEffects` field (38 added; motion/engine/forms already correct). Registration-bearing modules (render files' `registerRenderer`, `blocks/*/index.ts` `defineBlock` self-registration, widget `register.ts` files, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanket `false` on a package with registration or CSS.