Module development
Every feature in Kern is a module, and the first-party modules use exactly the shape you will. When you finish this page you have a module of your own — contract, server, screens and strings in one package — running inside a Kern on your machine, and a pair of container images that carry it to a server.
You need
Section titled “You need”- Node 24 and pnpm 10 (
corepack enable). - Docker with the Compose plugin.
- A Postgres 18 the module’s tests can create databases in. The
dev workspace starts one with
pnpm infra. - For step 3, the dev workspace itself:
git clone https://github.com/KernAIO/app && cd app && pnpm setup.
1. Start from the template
Section titled “1. Start from the template”-
Copy the template into a directory of your own:
Terminal window npx degit KernAIO/module-template module-crmcd module-crmResult: a package with
src/contract.ts,src/server/,src/client/,migrations/and aSTRUCTURE.mdthat says what every directory is for. It is a complete, working module — aNoteentity with list, create, archive and delete, its own schema, row-level security, permissions, capabilities, events, screens and strings. -
Give it its identity. The id must agree in four places, and each has been got wrong before:
Where Change package.jsonname—@acme/module-crmor any name; delete"private": trueor nothing will ever publishsrc/contract.tsMODULE_ID, and the prefix of every permission key and event namesrc/server/schema.tsmoduleSchema('crm')— the Postgres schema becomesmod_crmdrizzle.config.tsschemaFilter -
Install and prove the starting point is green:
Terminal window pnpm installDATABASE_URL=postgres://kern:kern@localhost:5432/kern pnpm typecheck && pnpm lint && pnpm test && pnpm buildResult: every command exits 0.
src/module.test.tswalks the contract and the router and fails when a procedure exists in one and not the other, or reaches the database without the workspace gate — keep it.
2. Make it yours
Section titled “2. Make it yours”Rename the Note entity and grow from there. The template’s README and STRUCTURE.md are the
reference; the rules that matter most:
- Version comes from the package, never a literal:
packageVersion(import.meta.url). - Write the RLS migration by hand.
pnpm db:generateemits tables and indexes, never a policy. Copymigrations/0001_rls.sqland change the table names; every tenant table carriesworkspace_idand a policy, and the kernel refuses to start in production under a role that bypasses them. - Every migration survives being applied twice.
drop policy if existsbefore everycreate policy,drop constraint if existsbefore everyadd constraint. The kernel migrates every hosted module at boot, so a migration that throws stops the whole service, not your module. - A screen reaches the shell only through
@kernhq/ui—session,navigation,getHost,t, the formatters, the components.$app/*,$lib/*and$msgare the application’s and do not exist in a package built on its own.pnpm typecheckhere is the only thing that sees that. - Strings ship in
src/client/i18n.ts. The platform’s locales areen,de,fa,arandtr; the starter is English only.
What the two halves may declare:
- The server — tables, migrations, a router,
proceduresother modules call throughkernel.call(),jobs,subscriptions, search indexers, object resolvers,httpRoutesfor a webhook that needs the raw body, and lifecycle hooks. - The client —
nav,routes,commands,settingsPages,widgetsfor the dashboard,sidebarfor the column beside the rail,presentersfor rendering your objects inside somebody else’s screen, andmessages.
Both entry points export the module as their default export. That is what the next two steps rely on.
3. Run it inside a local Kern
Section titled “3. Run it inside a local Kern”The umbrella workspace links any package under repos/, and the two host images take a list of
extra modules — the same mechanism the images use in step 4, so nothing is forked.
-
Put your module where the workspace sees it, and link it:
Terminal window mv ../module-crm repos/module-crm # inside the app checkoutAdd
"@acme/module-crm": "workspace:*"todependenciesin bothrepos/core/package.jsonandrepos/shell/package.json, then:Terminal window scripts/pnpm-install-locked.shResult:
readlink repos/shell/node_modules/@acme/module-crmprints a path underrepos/.corereads your package’s./serverand./contractfrom itsdist/, so runpnpm buildin the module after every server change; the shell reads./clientas source and sees an edit immediately. -
Generate the wiring in both hosts:
Terminal window (cd repos/core && KERN_EXTRA_MODULES=@acme/module-crm node scripts/extra-modules.mjs)(cd repos/shell && KERN_EXTRA_MODULES=@acme/module-crm node scripts/extra-modules.mjs)Result: each prints
extra-modules: @acme/module-crm.repos/core/src/extra-modules.tsandrepos/shell/src/lib/modules/extra.tsnow import your package. Do not commit those two files; running the script with the variable empty writes them back. -
Start everything:
Terminal window pnpm infra && pnpm devResult:
curl -s localhost:4000/api/healthlistscrmamong the modules, and/api/crm/openapi.jsondescribes your router. -
Open http://localhost:5173, sign in, and switch the module on in Settings → Modules.
Result: your navigation entry appears in the rail, your settings pages under Settings, your widgets in the dashboard’s add widget list, and your strings in whichever language the workspace uses.
A module that has never served a request is not finished, whatever the type-checker says.
4. Build the images that carry it
Section titled “4. Build the images that carry it”A self-hosted instance runs the two images Kern publishes — ghcr.io/kernaio/shell and
ghcr.io/kernaio/core — and a module has to be inside them: modules are composed at build time
(see ADR 0002).
Both Dockerfiles take KERN_EXTRA_MODULES, a space-separated list of npm package specs, install
them, and generate the same two files as step 3.
-
Publish the module to a registry the build can reach —
pnpm publishto npm, or a registry your build context’s.npmrcpoints at. -
Build both images from the same Kern release tag, with the same list. A shell that knows a module its core does not have calls procedures nobody serves:
Terminal window KERN=v0.2.1docker build --build-arg KERN_VERSION=${KERN#v} --build-arg KERN_EXTRA_MODULES="$MODS" \-t registry.example.com/acme/kern-core:${KERN#v} https://github.com/KernAIO/core.git#$KERNdocker build --build-arg KERN_VERSION=${KERN#v} --build-arg KERN_EXTRA_MODULES="$MODS" \-t registry.example.com/acme/kern-shell:${KERN#v} https://github.com/KernAIO/shell.git#$KERNResult: the build log shows
extra-modules: @acme/module-crmin each. A package the build cannot find fails the build there, by name, rather than producing an image without it. -
Push both images to your registry.
5. Run them on a self-hosted instance
Section titled “5. Run them on a self-hosted instance”-
In the instance’s
.env, point the two image variables at your registry and pin the version the pair was built from:KERN_IMAGE_CORE=registry.example.com/acme/kern-coreKERN_IMAGE_SHELL=registry.example.com/acme/kern-shellKERN_VERSION=0.2.1 -
Pull and restart:
Terminal window docker compose pull && docker compose up -dResult:
curl -s https://kern.example.com/api/healthlists your module, and it appears in Settings → Instance → Modules with the version your package declares.
Two things follow from carrying your own images:
- Every Kern release needs a rebuild of the pair before
KERN_VERSIONmoves. The updater will not do it for you — leave Settings → Instance → Updates on notify rather than auto, rebuild when a release arrives, then upgrade. KERN_VERSIONis still the version of Kern, baked into the image at build time. Your module’s own version is what/api/healthand Settings → Instance → Modules show beside its id.
Testing
Section titled “Testing”Unit-test contract logic with Vitest. For integration, boot the kernel against a scratch database —
createKernel({ service: 'test', modules: [crmModule] }) — and stub the core procedures your module
calls by registering them locally:
kernel.broker.register('core', { 'users.principal': { handler: async () => testPrincipal() } }).
@kernhq/testing carries permissionMatrixDiff, which the first-party modules use to pin which
built-in role holds each permission, and the tracker’s src/server/isolation.test.ts is the shape
of a cross-tenant test worth copying.
The template also ships src/server/migrations.test.ts, and it is worth keeping. It applies your
migrations/ folder to a database created from nothing, applies it a second time, and then asks the
Postgres catalogue which of your tables are actually secured. The replay is the half nothing else
covers: the kernel migrates every module at boot, in one process, before it binds a port, so a
migration that throws takes the other modules in that service down with it. Calling migrateModule
twice does not test this — the second call reads __migrations, sees the work recorded and returns.
Two lists keep it honest. Add every new tenant table to TENANT_TABLES in src/server/schema.ts.
If a table must stay outside a policy, name it in UNSECURED_BY_DESIGN in the test, with the reason
and the code that isolates it instead. A tenant table in neither list fails, 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.