Community Quickstart
This quickstart stands up a member organization: divisions, teams, ranks, positions, events and attendance from @wabbit/tome-org; an approval/transition state machine from @wabbit/tome-workflow for anything that needs a sign-off pipeline; a community currency — wallets and transactions, not real money — from @wabbit/tome-ledger; and multi-tenant accounts/projects from @wabbit/tome-accounts for a community that also needs a SaaS-shaped tenant boundary. These four compose side by side and don't depend on each other structurally. It assumes you have the block loop from Get Started working.
What the starter already gives you
If you forked tome-starter, org and ledger are wired and live:
@wabbit/tome-org— the 13-collection MVP slice (division, team, squad, position, membership, member, rank, promotion, event, event-attendance, event-type, campaign, document), plus opt-in depth (EventSlot, the Recognition cluster) the starter installs but doesn't switch on. Live on the starter's own/organd its sub-routes.@wabbit/tome-ledger— a "Demo Credits" wallet, credited on course completion, visible on the member's/accountpage.@wabbit/tome-accounts— installed and wired (as the directory layer's claimed-owner tenant), with its slug collision already resolved — see below.
@wabbit/tome-workflow is installed but not called directly by the starter. It's a required peer of @wabbit/tome-directory, which uses its claimTransition primitive internally for claim/review transitions — the starter itself never calls createWorkflowLayer() or builds its own transition table for org approvals. The wiring in this section is the package's own reference pattern, not something you can point at in the starter's org routes today.
Install the packages
All four are private (npm.wabbit.com) — confirm your .npmrc registry line and install token first (see Get Started).
npm install @wabbit/tome-org @wabbit/tome-workflow @wabbit/tome-ledger @wabbit/tome-accounts@wabbit/tome-org also peers on @payloadcms/richtext-lexical (required) and lucide-react (optional, for admin-nav icons). @wabbit/tome-ledger's only peer is payload itself — @wabbit/tome-core is optional there (the package imports nothing from it at runtime).
Wire the org layer
One call returns all 13 MVP collections. It also registers org's permission namespace into the shared @wabbit/tome-core permission engine — there is no parallel org resolver:
import { buildConfig } from 'payload'
import { createOrgLayer } from '@wabbit/tome-org'
export default buildConfig({
collections: [
...existingCollections, // incl. users + a media collection
...createOrgLayer({
userCollection: 'users',
mediaCollection: 'media',
terminology: { division: { singular: 'Wing', plural: 'Wings' } },
}),
],
})terminology renames the vocabulary only — the underlying collection slugs and shapes are unchanged, so a "Wing" is still a org-divisions row. Events, attendance, ranks, and promotions all ship in the same 13-collection call; nothing further to wire for the MVP slice.
Two clusters are deliberately not part of createOrgLayer — call their factories directly if you need them: Recruiting (createApplicationCollection, createJoinRequestCollection, createOnboardingRecordCollection) and Recognition (createAwardCollection, createMedalCollection, createRibbonCollection, createAwardPresentationCollection, createKudosCollection, createMemorialCollection).
Wire an approval flow
@wabbit/tome-workflow ships no collections at all (collections: []) — it's an engine you point at a status field you already own, plus a declarative transition table. Define the table, then drive a transition through guardedTransition from a request handler or job runner (never at config-graph load time):
import type { WorkflowTransitionTable } from '@wabbit/tome-workflow'
import { guardedTransition } from '@wabbit/tome-workflow/server'
export const promotionApprovalTable: WorkflowTransitionTable = {
initial: 'pending',
transitions: [
{ from: 'pending', to: 'approved', requiresRole: 'admin', sideEffect: 'apply-promotion' },
{ from: 'pending', to: 'denied' },
],
}
const outcome = await guardedTransition(payload, {
collection: 'org-promotions',
id: promotionId,
table: promotionApprovalTable,
from: 'pending',
to: 'approved',
actorId: user.id,
hasRole: (role) => userHasRole(user, role),
})
// outcome.status: 'invalid-transition' | 'wrong-actor-class' | 'requires-role'
// | 'recused' | 'transition-guard-failed' | 'already-claimed'
// | 'side-effect-failed' | 'executed'The win is a raw compare-and-swap, and it bypasses Payload hooks by design — on the Mongo adapter (this platform's only proven-atomic path), the claim writes directly through the driver, so no beforeChange/afterChange on your collection fires automatically. Orchestrate side effects explicitly right after a winning claim (the pattern above, and what guardedTransition itself does via the sideEffect key + defineWorkflowSideEffect), or pass dispatchHooks: true to claimTransition if a collection-level afterChange genuinely needs to observe the write.
Wire the ledger (Preview — the API may still move between minor versions)
There is no createLedgerLayer() — community currency is composed one collection at a time, deliberately. Order matters: Currency and Wallet before Transaction, since Transaction's relationships reference both by slug.
import {
createCurrencyCollection,
createWalletCollection,
createTransactionCollection,
} from '@wabbit/tome-ledger'
// ...add to your config's `collections` array, in this order:
createCurrencyCollection({ adminGroup: 'Community' }),
createWalletCollection({ adminGroup: 'Community', ownerRelationTo: ['members'] }),
createTransactionCollection({
adminGroup: 'Community',
compatibilityMode: 'strict', // the default; shown for emphasis
}),Fail-closed by default. Every factory's default access denies writes outright (Transaction denies reads too) — pass your own access or write with overrideAccess: true. Wallet.balances is locked at the field level regardless of collection access (update always denied) — only the transaction hook moves money.
Idempotent by construction. In strict mode (the default): a debit that would take a wallet below zero throws NegativeBalanceError before any write; a duplicate idempotencyKey on create throws DuplicateIdempotencyKeyError rather than double-applying on a retry; a money-moving transition with no req.user throws MissingActorError unless you declare a system write via context: { ledgerSystemContext: true }. Balances change only when a transaction reaches completed — moving a completed transaction back to pending/rejected reverses it. A 'legacy-vngd' compatibility mode exists for a migrating consumer that needs the donor implementation's exact (weaker) holes during cutover — leave it at strict for a new site.
Read a balance from server code: getWalletBalance(walletId, currencyId, payload) from @wabbit/tome-ledger/server — returns 0 when no entry exists yet.
Wire accounts & the slug collision
createAccountsLayer returns the Account/Membership/Project trio and registers the account permission namespace, same converged-engine posture as org:
import { createAccountsLayer } from '@wabbit/tome-accounts'
createAccountsLayer({ memberSlug: 'members' })
// Phase-1b governance is opt-in:
createAccountsLayer({ memberSlug: 'members', auditLog: true, approvals: true })The default accounts slug collides with better-auth's own account model. Tome-core's auth factory generates a Payload collection for better-auth's account bookkeeping model (OAuth provider links), and by default that collection is slugged accounts too — the exact string createAccountsLayer defaults to. Two rows fighting over one slug means the platform refuses to boot. Resolve it on one side only:
// Option A — rename the accounts layer's own slug (what the starter does
// for @wabbit/tome-directory's owner-account tenant):
createAccountsLayer({ memberSlug: 'members', slugs: { accounts: 'owner-accounts' } })
// Option B — rename better-auth's side instead, in your betterAuthFactory config:
createBetterAuthConfig({
// ...
internalModelNames: { account: 'authAccount' }, // generated slug becomes 'authAccounts'
})Option A is what the starter actually ships (verified in src/layers/directory.config.ts: DIRECTORY_ACCOUNTS_SLUG = 'owner-accounts') — better-auth's collection keeps its historical accounts slug, and the accounts layer moves instead. Option B is the inverse: tome-core's auth factory accepts internalModelNames.account to rename better-auth's bookkeeping model before Payload slug derivation, opt-in and off by default so upgrading tome-core never silently renames a live collection. Pick one — flipping it later on a site with existing rows orphans whichever side you didn't migrate.
The managed tier
All four packages above are composed by you into your own Payload config — there is no single "community layer" call. The fully-managed Community capability tier — where we run the org structure, approval routing, and ledger integrity contract with you — is set up through a request-access conversation, not a checkout button. If that's where you're headed, reach out. For what a subscription keeps buying you after install, see Versioning & updates.