The starting point for a Kern module. Apache-2.0, so what you build from it is yours to license however you like — including not at all.
npx degit KernAIO/module-template my-moduleA Kern module is one package. This one is a whole working module — a Note entity with list,
create, delete and archive, its own Postgres schema, row-level security, permissions, capabilities,
events, its own screens and its own strings — and a test that refuses to let the contract and
the router drift apart.
The application holds no screens belonging to a module. Deleting your package removes your feature completely; that is the test of whether it is a module at all.
STRUCTURE.md is the map — what every directory is for, and the one part that is
optional. In short: most modules are contract + server + client, and core hosts them. A
module that needs its own process — an open socket, a queue it drains on its own clock — is hosted
by a sibling service repository (see KernAIO/chat and KernAIO/mail), which declares it in
featureModules and ships the Dockerfile. Nothing else changes.
| File | What it is |
|---|---|
src/contract.ts |
Zod models, the oRPC contract, events, permission keys, capabilities. Imported by both halves, so no Node code. |
src/server/schema.ts |
Drizzle tables in mod_<id>. |
src/server/_impl.ts |
The router. Separate from index.ts so the test can walk it without a kernel. |
src/server/index.ts |
defineServerModule — schema, migrations, router, subscriptions. |
src/client/index.ts |
The typed API client and module logic. Ships as source. |
src/module.test.ts |
Contract-to-router parity and the authorisation guard. Keep it. |
src/server/migrations.test.ts |
Replays the migration folder, then audits row-level security against the Postgres catalogue. Keep it. |
migrations/0000_init.sql |
Generated by pnpm db:generate. |
migrations/0001_rls.sql |
Hand-written. Never generated. |
Each of these has been got wrong before.
package.json— setnameto@kernhq/module-<id>, and delete"private": true. A private package is skipped silently by changesets: the commit lands, CI is green, and nothing publishes.filesmust cover every directory./clientreaches —src/clientandsrc/contract. The client ships as source, so a re-export the tarball omits breaks the consumer and nothing local notices, because the workspace resolves the file the package does not ship.pnpm check:packcatches it.- The id agrees in four places:
MODULE_ID,moduleSchema('<id>'),schemaFilterindrizzle.config.ts, and every permission and event prefix. - Version comes from the package, never a literal:
packageVersion(import.meta.url). A literal is not bumped by a release — chat once shipped as 0.2.0 while telling every admin it was 0.1.0, and that literal is whatworkspace_modules.installed_versionrecorded. - Write the RLS migration.
pnpm db:generatewill not. Copy0001_rls.sqland change the table names;rlsPolicySqlfrom@kernhq/kernelemits the same four statements. See Your migrations must survive a replay below for why thedrop policy if existsis one of them. - Host it. A module nothing imports is invisible: its tests pass, it publishes, and every call
404s. Add it to
featureModulesin thecorerepo'ssrc/service.ts, or to whichever service should hold it. - Register the client. One line in the app's
src/lib/modules/registry.ts:registerModule(crmClientModule), importing from@kernhq/module-crm/client. Together with step 6 that is the only wiring outside this package.
src/server/migrations.test.ts applies migrations/ to a database created from nothing, applies it
a second time, and then asks the Postgres catalogue which of your tables are actually secured. Keep
it, and keep it passing. It guards two failures that nothing else in a module's suite can see.
A migration that throws stops the whole host service. The kernel migrates every module at boot,
in one process, before it binds a port. So a module whose SQL fails does not degrade its own
feature — it takes the other modules in that service down with it, and core hosts five. A replay
is ordinary rather than rare: drizzle keys applied migrations by content hash, so editing any file
in migrations/ makes drizzle run every file in it again. create policy and add constraint have
no if not exists, and create table and create index only get one if you write it.
A tenant table with no policy is simply readable. Row-level security is hand-written, so forgetting it on a new table leaves a schema that is valid, silent and unprotected. The test asks the catalogue rather than a list you maintain, so a table you added and did not secure fails by name.
Two lists keep it honest, and both live in your module:
- Add every new tenant table to
TENANT_TABLESinsrc/server/schema.ts. - If a table must stay outside a policy, name it in
UNSECURED_BY_DESIGNin the test, with the reason and the code that isolates it instead.
A tenant table in neither list fails the test, so adding one is a decision you record. An entry in
UNSECURED_BY_DESIGN that has since been given a policy also fails, so the list cannot quietly go
stale.
The server half declares tables, migrations, a router, procedures other modules call through
kernel.call(), jobs, subscriptions, search indexers and lifecycle hooks.
The client half — src/client/module.ts in this package — declares nav, routes, commands,
settingsPages, widgets for the dashboard, sidebar for the column beside the rail, presenters
for rendering this module's objects inside somebody else's screen, and messages for its own
strings. The shell renders whatever it finds; there are no route files in the application to keep in
step. Read the kern-widget skill before writing a widget, and kern-module for the whole sequence.
Internationalisation. The starter ships its strings in English only, by design; the platform's
locales are en, de, fa, ar and tr, and a module author adds the bundles their own module
needs in src/client/i18n.ts.
A module cannot import the application, so everything it needs from the shell comes from
@kernhq/ui: session (who is signed in, what they may do, which capabilities the workspace has),
navigation (where we are, go, describe), getHost (the API origin, whether the mock is
running), t (this module's strings and the shared common bundle), the formatters, realtime,
uploadFile, WidgetState, the design-system components and the charts.
Three things will compile while you are editing inside the app and fail the moment this package is built on its own — so they are worth knowing before you write them:
$app/stateand$app/navigationdo not exist here. A route component is passedworkspaceId,workspaceSlugandparams; anything else asksnavigation.$lib/*and$msgare the application's aliases. Your strings live insrc/client/i18n.ts.- Importing this package's own barrel (
./index.js) from inside it is a cycle. Name the file. The barrel re-exports the manifest, which reaches Svelte — so a pure-function test that goes through it fails with$state is not defined.
src/server/migrations.test.ts needs a real Postgres — it creates a scratch database, applies your
migrations to it and drops it again. Nothing is mocked, because the failures it looks for live in
the Postgres catalogue. Point DATABASE_URL at a database you can create databases from; without
it the suite falls back to postgres://kern:kern@localhost:5432/kern. CI starts one as a service
container, in .github/workflows/ci.yml.
export DATABASE_URL=postgres://kern:kern@localhost:5432/kern
pnpm typecheck && pnpm lint && pnpm test && pnpm buildAll four report success, and pnpm test names your migration test among the files it ran.
Then check by hand the two things no script here checks: that the tarball npm pack produces
contains every directory ./client imports from, and that the manifest reports
packageVersion(import.meta.url) rather than a literal.
Then use it through the interface, signed in, with the module enabled for a workspace. A module that has never served a request is not finished, whatever the type-checker says.