Skip to content

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.

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.

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 }
},
},
})
}

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 }
},
},
})
}

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.

Modules talk to each other through events and commands, never imports.

// Module A — observes an event, dispatches a command
defineKitModule({
name: 'a',
setup(runtime) {
runtime.on('user.signed_in', () => {
runtime.command({ type: 'session.start' })
})
},
})
// Module B — handles the command
defineKitModule({
name: 'b',
commands: {
'session.start': () => createSession(),
},
})

Modules can be developed and tested independently. Coupling lives in the event/command vocabulary, not in import graphs.

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.

events: {
'*': (event) => {
// observes every event
},
}

Useful for analytics, audit, debug. Use sparingly — many wildcard observers can accumulate cost.

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 }.

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.

  • 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.
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.