@kernhq/module-template
v0.2.6
Published
Apache-2.0 starting point for a Kern module — copy it, or read it as the reference for what a module is
Maintainers
Readme
@kernhq/module-template
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.
npm create kern-module # not yet; for now:
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 — adds
src/service/ and a Dockerfile, and hosts itself. Nothing else changes.
What is in here
| 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. |
| migrations/0000_init.sql | Generated by pnpm db:generate. |
| migrations/0001_rls.sql | Hand-written. Never generated. |
Copying it by hand
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 text. - 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.
What a module can contribute
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.
What a screen may reach for
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.
Before you call it done
pnpm typecheck && pnpm lint && pnpm test && pnpm build
pnpm check:pack # the tarball contains what ./client imports
pnpm check:versions # the manifest version matches package.jsonThen 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.
