Payments needs to write an audit event. Support needs to know who a user is. Matchmaking needs a skill rating. Leaderboard needs an authenticated caller.
Every one of those is a capability needing something another capability provides, and how you answer it decides whether you have a kit or a tangle.
The rule here is one line: a capability may depend on a core seam, but never on a sibling.
What a capability is
You compose one in pithy.config.ts, and it brings everything it needs with it: routes, tables, migrations, the Cloudflare bindings it requires, and its own config schema.
Composition is library-before-app into a single registry — so capabilities assemble first, then your application’s own contributions land on top and can reference them.
That is the ordinary part. The rule is what makes it hold together at seventeen capabilities instead of falling apart at five.
Why not just import the sibling?
Because dependency edges between siblings multiply, and each one is a decision somebody has to keep true.
If payments imports audit, then payments does not work without audit. If matchmaking imports rating, you cannot ship matchmaking to someone who does not want skill-based queues. Adopt three capabilities and you have adopted their transitive graph, whether or not you wanted it.
So the contract goes into core, which everything already depends on, and both sides talk to that.
payments records an entitlement grant through the audit seam. It never imports @pithy-sh/audit. Compose audit and the event lands in your trail; do not, and the call is a no-op.
The uncomposed default is a real decision
Which brings up the part worth thinking hardest about.
Audit’s seam no-ops when nothing is composed. That is safe — a missing audit write cannot grant anyone access.
The entitlement seam denies. Same pattern, opposite default, because an entitlement check is a gate and a missing provider must fail closed. If it no-oped like audit, removing a package from your config would silently unlock every paid feature you sell.
The consistent choice would have been the catastrophic one. That distinction gets its own post, because it generalizes to any plugin seam you build.
Sharing a binding without sharing a schema
Several capabilities put tables in the same D1 database. They do not coordinate to do it.
Each declares its tables against a named binding, and composition groups them — one binding, many capabilities’ items, assembled into a coherent whole with migration order deciding who lands first.
The upshot is that audit and payments and auth share your DB binding and one pithy_migrations table, rather than each demanding a database and leaving you to reconcile them.
Two request variables, and why not one
Here is the sharpest example of the rule doing security work.
Every request carries typed variables that capabilities read. Two of them look similar: auth holds the authenticated user, and controlPlane holds a verified management client.
The tempting design is one variable with a flag — a caller is a caller, and a management client is just another kind of authenticated one.
That is a scope escalation, and the source says so plainly. A management client is not a user of your app. It holds no session and owns no user row. If a control-plane call populated auth, then every requireAuth() in every capability would pass for it — and each of those checks was written meaning a signed-in person, not anything that authenticated somehow.
So they are separate variables, read through separate guards. A route that wants a user gets a user. A route that wants a management client asks for one by scope. Neither can be satisfied by the other, because they are not the same field.
What you get
Adopt in pieces. Compose matchmaking without rating and the queue buckets by region alone. Add rating later and it starts bucketing by skill. Nothing was rewritten.
Absent capabilities fail the right way. Leaderboard declares no dependency on auth, yet every route denies without it — because routes read the auth seam, and no auth means no caller, and no caller is denied.
Capabilities bring their own admin surface. Compose payments and its manual grant and revoke routes appear behind the control plane, already gated, with nothing to wire.
Your own code is a peer. You write capabilities the same way the kit does — same defineCapability, same seams, same migration ordering. There is no privileged internal API you are locked out of.
The cost
Seams are indirection, and indirection is a real price. Reading how payments writes an audit event means finding the seam in core rather than following an import.
That is the trade, and it is worth taking at this size. At two capabilities, direct imports would be simpler and I would recommend them. At seventeen, the graph is the thing that would have killed it.