Modules
A module is a value with a name and one or more of: event handlers, command handlers, providers, and lifecycle hooks. You install it on the runtime; it observes events, handles commands, exposes services, and the UI never imports it.
import { defineKitModule } from '@atheory-ai/kitsune-core'
export const auditModule = defineKitModule({ name: 'audit', events: { 'note.saved': (event) => writeAudit(event), 'note.deleted': (event) => writeAudit(event), },})That’s a module. Three properties.
Anatomy
Section titled “Anatomy”A module has six possible fields:
export type KitModule = { name: string
setup?: (runtime: KitRuntime) => void | Promise<void> start?: (runtime: KitRuntime) => void | Promise<void> stop?: (runtime: KitRuntime) => void | Promise<void>
events?: Record<string, EventHandler> commands?: Record<string, CommandHandler>}name — for diagnostics and (eventually) devtools.
setup — runs at install time. Use this to register provider tokens, prepare state, do work that can be done synchronously before the runtime starts.
start — runs after the shell has installed all modules and the runtime is ready. Use this for I/O that depends on other modules being installed.
stop — runs when the runtime tears down. Cleanup goes here.
events — a map of event type → handler. Multiple modules can subscribe to the same type; all run.
commands — a map of command type → handler. The latest registered handler wins.
Installation
Section titled “Installation”const shell = document.querySelector('kit-shell')shell.modules = [auditModule, notificationModule(), debugModule()]Or programmatically:
const runtime = createKitRuntime()await runtime.install(auditModule)await runtime.start()Order doesn’t matter for events. For commands, the last installed wins — useful for overriding default modules in tests.
A module with state
Section titled “A module with state”Modules can hold their own state.
import { defineKitModule } from '@atheory-ai/kitsune-core'
export function auditModule() { const log: Array<{ type: string; timestamp: number }> = []
return defineKitModule({ name: 'audit', events: { '*': (event) => { log.push({ type: event.type, timestamp: event.timestamp }) if (log.length > 100) log.shift() }, }, })}Closure-scoped state stays private to the module. No global registries, no implicit access.
A module with a provider
Section titled “A module with a provider”Sometimes a module needs to expose a service that other modules or UI code can read. Use a provider token.
import { createProviderToken, defineKitModule } from '@atheory-ai/kitsune-core'
export type StorageService = { get(key: string): unknown set(key: string, value: unknown): void}
export const StorageToken = createProviderToken<StorageService>('storage')
export function localStorageModule() { return defineKitModule({ name: 'localStorage', setup(runtime) { runtime.provide(StorageToken, { get: (k) => JSON.parse(localStorage.getItem(k) ?? 'null'), set: (k, v) => localStorage.setItem(k, JSON.stringify(v)), }) }, })}Another module can read it:
const storage = runtime.inject(StorageToken)storage?.set('quill.notes', notes)This is how modules cooperate without importing each other.
A module that talks to other modules
Section titled “A module that talks to other modules”Sometimes a module needs to emit an event or dispatch a command of its own.
export const draftAutosaveModule = defineKitModule({ name: 'draft-autosave', setup(runtime) { let pending: ReturnType<typeof setTimeout> | undefined runtime.on('draft.changed', () => { clearTimeout(pending) pending = setTimeout(() => { runtime.command({ type: 'draft.save' }) }, 500) }) },})The autosave module observes draft.changed and dispatches draft.save after a debounce. Whoever handles draft.save (a localStorageModule, an HTTP module, a Supabase module) does the actual work.
This is the composability payoff. Three modules — one observes, one debounces, one persists — together implement autosave. Any of them can be replaced.
A module that registers commands at runtime
Section titled “A module that registers commands at runtime”Use setup to register handlers programmatically when you need conditional logic:
export function clipboardModule(options: { fallback?: 'noop' | 'alert' } = {}) { return defineKitModule({ name: 'clipboard', setup(runtime) { if (navigator.clipboard) { runtime.handleCommand('clipboard.copy', async (cmd) => { await navigator.clipboard.writeText(String(cmd.payload?.text ?? '')) return { copied: true } }) } else if (options.fallback === 'alert') { runtime.handleCommand('clipboard.copy', (cmd) => { window.alert('Copy: ' + cmd.payload?.text) return { copied: false, fallback: 'alert' } }) } }, })}Testing a module
Section titled “Testing a module”Modules are values. Test them by installing them on a fresh runtime.
import { describe, it, expect } from 'vitest'import { createKitRuntime } from '@atheory-ai/kitsune-core'import { auditModule } from './audit-module'
describe('auditModule', () => { it('records note.saved', async () => { const runtime = createKitRuntime() await runtime.install(auditModule()) await runtime.start()
runtime.emit({ type: 'note.saved', payload: { id: '42' } }) // assert against the module's exposed state or a spy })})No DOM required. No browser required. Modules are pure runtime values.
Conventions for module names
Section titled “Conventions for module names”kit-prefix for built-in modules:kit-dialog,kit-notifications,kit-debug.- Use kebab-case.
- Keep names short and refer to the capability, not the implementation.
analytics, notposthog-analytics-v2.
Read next
Section titled “Read next”- Providers — long-form on the provider system
- Diagnostics — what modules can see about the runtime
- Reference: Authoring Modules
- Tutorial chapter 5: Persistence Module — write your first non-trivial module