Authoring Modules
A module is a value with a name and (optionally) setup, start, stop, events, and commands. Use defineKitModule for type inference.
import { defineKitModule } from '@atheory-ai/kitsune-core'
export const myModule = defineKitModule({ name: 'my-module', events: { 'thing.happened': (event) => console.log(event) },})This page covers patterns that come up repeatedly.
Factory vs value
Section titled “Factory vs value”If your module has options or holds state, return it from a factory:
export function auditModule(maxEntries = 50) { const entries: AuditEntry[] = []
return defineKitModule({ name: 'audit', events: { 'note.created': (e) => entries.unshift(/* ... */), }, })}
shell.modules = [auditModule(100)]If your module has no state and no options, export a plain value:
export const auditModule = defineKitModule({ name: 'audit', events: { /* ... */ },})
shell.modules = [auditModule]The factory pattern is more common — it gives you a place to hold options and per-instance state.
Holding state
Section titled “Holding state”Module state is closure-scoped. No globals, no dependency injection, no framework-specific stores.
export function counterModule() { let count = 0
return defineKitModule({ name: 'counter', commands: { 'counter.increment': () => { count++ return { count } }, }, })}Exposing services via providers
Section titled “Exposing services via providers”When state needs to be readable by other code (UI, other modules), expose a provider token:
import { createProviderToken, defineKitModule } from '@atheory-ai/kitsune-core'
export type CounterService = { current(): number subscribe(listener: () => void): () => void}
export const CounterToken = createProviderToken<CounterService>('counter')
export function counterModule() { let count = 0 const listeners = new Set<() => void>() const notify = () => listeners.forEach((l) => l())
const service: CounterService = { current: () => count, subscribe: (l) => { listeners.add(l); return () => listeners.delete(l) }, }
return defineKitModule({ name: 'counter', setup(runtime) { runtime.provide(CounterToken, service) }, commands: { 'counter.increment': () => { count++ notify() return { count } }, }, })}Async setup
Section titled “Async setup”setup, start, and stop can be async:
defineKitModule({ name: 'storage', async setup(runtime) { const db = await openDatabase('quill') runtime.provide(StorageToken, db) },})The shell awaits each module’s setup before installing the next.
Cross-module communication
Section titled “Cross-module communication”Modules talk to each other through events and commands, never imports.
// Module A — observes an event, dispatches a commanddefineKitModule({ name: 'a', setup(runtime) { runtime.on('user.signed_in', () => { runtime.command({ type: 'session.start' }) }) },})
// Module B — handles the commanddefineKitModule({ name: 'b', commands: { 'session.start': () => createSession(), },})Modules can be developed and tested independently. Coupling lives in the event/command vocabulary, not in import graphs.
Lifecycle hooks
Section titled “Lifecycle hooks”defineKitModule({ name: 'analytics',
// Synchronous setup at install time. Runtime is available. setup(runtime) { runtime.provide(AnalyticsToken, createClient()) },
// After all modules installed and other modules ready. start(runtime) { const client = runtime.inject(AnalyticsToken) void client?.flush() },
// On runtime stop. Cleanup goes here. stop(runtime) { runtime.inject(AnalyticsToken)?.flush() },})setup runs in installation order. start runs after all modules installed. stop runs in reverse installation order.
Wildcard event subscription
Section titled “Wildcard event subscription”events: { '*': (event) => { // observes every event },}Useful for analytics, audit, debug. Use sparingly — many wildcard observers can accumulate cost.
Returning command results
Section titled “Returning command results”Command handlers can return a value ({ ok: true, value: ... }) or throw ({ ok: false, error }):
commands: { 'note.create': async (command) => { const note = await api.createNote(command.payload) return { id: note.id } }, 'note.delete': async (command) => { const ok = await api.deleteNote(command.payload?.id) if (!ok) throw new Error('not found') return { deleted: true } },}Callers do if (result.ok) { result.value }.
Emitting events from a module
Section titled “Emitting events from a module”A module that handles a command often emits a corresponding event so other modules can observe:
commands: { 'note.create': async (command) => { const note = await api.createNote(command.payload) runtime.emit({ type: 'note.created', payload: { note } }) // for observers return { id: note.id } },}Notice the tense difference: note.create (imperative command) vs note.created (past-tense event). This is the standard pattern.
Module shape conventions
Section titled “Module shape conventions”- Name with
kit-prefix only for built-ins. Your modules:audit,analytics,sync. Kitsune modules:kit-dialog,kit-notifications. - Export a factory unless trivially stateless. It scales better when you want options later.
- Keep modules under ~150 lines. If a module gets larger, it’s probably two modules.
- Document the events/commands the module handles. Usually as a JSDoc on the factory.
Testing
Section titled “Testing”import { describe, it, expect } from 'vitest'import { createKitRuntime } from '@atheory-ai/kitsune-core'import { counterModule, CounterToken } from './counter-module'
describe('counterModule', () => { it('increments', async () => { const runtime = createKitRuntime() await runtime.install(counterModule()) await runtime.start()
await runtime.command({ type: 'counter.increment' }) await runtime.command({ type: 'counter.increment' })
expect(runtime.inject(CounterToken)!.current()).toBe(2) })})No DOM, no browser. Modules are pure runtime values.
See also
Section titled “See also”- Modules (concept)
- Providers (concept)
- Tutorial: Chapter 5 — first non-trivial module
- Tutorial: Chapter 10 — modules as the architecture’s payoff