Everything here is a plugin, including everything we wrote
Content, media, translation, the dashboard, the assistant — none of them is built into the core, because there is barely a core. The application is a small host that turns a list of plugins into a running system, and your plugin is registered in that same list.
For engineers, agencies, and platform teams who have been burned before
The host knows nothing
There are two hosts — one for the API and one for the admin — and neither contains any domain logic. Each takes an array of plugins and assembles an application from it. Removing the media plugin from that array produces a CMS with no media library and no dead code paths, because nothing else ever imported it.
A server plugin owns its own database schema and its own migrations. The shared database plugin owns the single connection and no tables at all. That is what keeps one capability’s storage from becoming another’s problem, and it is why a plugin can be added or removed without a coordinated migration across the whole product.
Plugins never reach into each other. They integrate through declared ports — a symbol and an interface, bound by whoever implements it — and through named UI slots. The media plugin binds the port content declares for resolving assets; unbound, media fields still validate their shape and store, they just skip the existence check. Absent collaborators degrade rather than crash.
A host with four plugins docked into it: content, media, copilot and one of your own, all registered the same way.
What ships as a plugin
Not a list of integrations — a list of the product.
| Plugin | What it contributes |
|---|---|
| content | The model, the entries, revisions and the public API. |
| media | Folders, assets and the storage seam. |
| i18n | Locales, the fallback chain and per-locale rows. |
| identity | Sign-in, invites, roles and API tokens. |
| workspaces | The tenancy boundary and its guards. |
| activity | The audit log and its subscriber. |
| insights | The dashboard frame — and none of its panels. |
| alarms | Content rules and the findings they raise. |
| segments | Reader entitlements on the public API. |
| copilot | The assistant, its run engine and its providers. |
| mcp | The endpoint external agents connect to. |
| content-graphql | GraphQL, as an adapter over the same services. |
Each is a package under packages/<name>/{admin,server}, and each is registered in one array in your application.
Named places to contribute, on both sides
The admin has thirteen named extension slots. A plugin contributes an item to one and the slot’s owner renders it, with neither side importing the other: the sidebar, the command palette, the home page, the workspace navigation and routes, the entry editor’s tabs and menus, the dashboard’s panels and its bands, and the rich-text editor’s media sources.
The dashboard is the clearest case. The insights package ships the page, the grid and the card shell, and contributes zero panels — every card on it arrives through a slot from the plugin that owns the numbers. A dashboard is the module most likely to slowly become the one that knows about everything else, and the slot is the thing that prevents it.
Bands are contributions too, so there is no privileged set. Contributing a band with the same id as a built-in merges into it field by field — renaming a section without resetting its order or moving it — which means overriding ours and adding your own are the same act.
Why this is the part that decides whether you outgrow it
- The extension API is the API we use
- There is no internal fast path we kept for ourselves and no privileged plugin. If the contract can express our content plugin, it can express yours — and when it cannot express something, that is a bug in the contract rather than a feature request for a hook.
- Your tools reach the agents too
- A plugin contributing a tool to the shared registry has it available to the in-product assistant and to external MCP clients alike, with the same permission check. Extending the product extends what an agent can do with it.
- A missing plugin is a smaller product, not a broken one
- Optional dependencies are injected optionally throughout. A deployment without the MCP endpoint still runs the assistant; a deployment without media still stores media field values.
- Conventions are enforced, not suggested
- Plugin authoring rules live as tooling in the repository rather than as a page in a wiki, which is the only version of "conventions" that survives a year.
Questions before you commit
What language is a plugin?
TypeScript. A server plugin is a NestJS module with a Drizzle schema; an admin plugin is a React package contributing to slots. If your team writes either of those, they can write a plugin.
Do I have to fork to add something?
No, and that is the point of the shape. Your plugin is a package in your own repository, added to the array your application already maintains. Upgrading Apograph does not replay your changes.
Can I replace something you shipped?
Yes for anything slot-based — contribute with the same id and yours wins. Replacing a whole server plugin means removing it from the array and registering yours, which works but means owning its schema.
Will there be a plugin marketplace?
No. Plugins are packages in your build, not artefacts uploaded to a running server, so there is nothing to install at runtime and nowhere for a marketplace to install it to.
Next
Every feature is included, free
The core is MIT licensed and every feature is in it — none of them is paid-only. Community runs free in production; a plan buys room and governance, not a different product.