Every Throughline package is a Payload plugin: a function (config) => config that adds collections, fields, hooks, endpoints, and onInit handlers. Plugins find each other through a small registry on the Payload instance and assert their dependencies at boot time.
The framework needs to compose with whatever else a Payload project is doing — custom collections, third-party Payload plugins, the host's own access controls. A monolithic SDK ("call setupThroughline(payload)") would force ordering and conflict with that. Plugins compose by default.
Plugins also let clients pick up only what they need. A site without forms doesn't need the Forms plugin. The package is install-then-list-in-config and nothing more.
Always load Throughline plugins in this order:
auditPlugin— first, because every other plugin writes to the audit logcomponentsPlugin— composition validation; publishing depends on itpublishingPlugin— depends on components for composition checksapprovalsPlugin— depends on audit; publishing optionally depends on itsgetActiveApprovalauditQueryPlugin— read-only; depends on the audit log existingemailPlugin— fires onapproval/*eventsformsPlugin— fires onform/*events; depends on email + auditintegrationsPlugin— last, because it subscribes to events from the others
Plugins assert their dependencies at boot via requireCapability(name, callerId). If a plugin's prereq isn't loaded, the app fails to start with a clear message — not silently with broken behavior at runtime.
Capabilities are how plugins advertise to each other. The audit plugin registers audit-log, the components plugin registers components and composition-validator, etc. Other plugins call:
import { requireCapability } from '@forumone/throughline-core'
requireCapability('audit-log', '@forumone/throughline-publishing')requireCapability throws synchronously during plugin init if the capability isn't registered yet, which forces the order described above. Order is enforced at config-build time, not at request time.
Some plugins need to expose richer surfaces than capabilities (which are boolean): the email plugin exposes a send client; the integrations plugin exposes its registry; the email plugin exposes its compiled functions array. They use Symbol.for(...) keys on the Payload instance:
// Inside a plugin's onInit:
const KEY = Symbol.for('@forumone/throughline-email/getClient')
;(payload as any)[KEY] = createEmailClient(options)// In a consumer (e.g. apps/web/src/app/api/inngest/route.ts):
import { getEmailFunctions } from '@forumone/throughline-email'
const fns = getEmailFunctions(payload) ?? []The plugin exports getEmailFunctions(payload) (a small accessor) so consumers don't have to know the symbol. Two plugins running the same version see the same Symbol.for(...) key — the registry is identity-based across modules.
This pattern keeps plugins decoupled. Email doesn't know about Forms. Forms looks up email lazily at the moment it sends, so plugin-load order is the only order that matters.
- Plugin function:
<feature>Plugin(camelCase) —publishingPlugin - Options type:
<Feature>PluginOptions(PascalCase) —PublishingPluginOptions - Package entry point: re-exports both
- Capability name: short kebab-case slug —
audit-log,composition-validator - Symbol key:
Symbol.for('@forumone/throughline-<package>/<accessor>')
import type { Plugin } from 'payload'
import { auditPluginEntry, registerCapability } from '@forumone/throughline-core'
export const examplePlugin = (options: ExamplePluginOptions): Plugin =>
(incoming) => {
return {
...incoming,
collections: [
...(incoming.collections ?? []),
createExampleCollection(options),
],
endpoints: [
...(incoming.endpoints ?? []),
createMcpEndpoint({ id: 'example', tools, options }),
],
onInit: async (payload) => {
await incoming.onInit?.(payload)
registerCapability(payload, 'example', '@forumone/throughline-example')
// expose your runtime API on the payload instance via Symbol.for
// attach Inngest workers, etc.
},
}
}The plugin returns a new config — never mutates the incoming one in place — and always invokes the upstream onInit so chained plugins all get notified.
- Mutate other plugins' collections. If you need a collection to behave differently, your plugin owns its own collection or contributes fields to a shared one through a documented hook.
- Read state from each other directly. Either expose a capability + getter (cross-plugin API) or fire an event (loose coupling).
- Patch Payload internals. If a feature requires modifying Payload itself, it belongs in core.
Each plugin ships with tests that build a fake Payload config, run the plugin's mutation, and assert the resulting config has the expected collections, endpoints, and onInit behavior. The fakes are deliberately minimal — they expose just enough surface for plugin tests to work without spinning up a real database. See packages/audit/src/_fixtures.ts for the pattern.
packages/core/src/capabilities.ts— the capability registrypackages/<each>/src/plugin.ts— the entry functionapps/web/src/payload.config.ts(in a generated project) — the canonical order