The Runtime Loop
The runtime loop is the central thing in Kitsune. Everything else — boundaries, modules, commands, providers — exists to serve it.
1. User interacts with a UI element2. The element exposes meaning (an event type or command type, with payload)3. The boundary enriches with context (surface, feature, entity)4. The runtime emits the event or dispatches the command5. Modules observe events or handle commandsThen the loop continues, because module behavior typically updates state, which updates the UI, which becomes the next interaction.
The five stages, with code
Section titled “The five stages, with code”Take this markup:
<kit-shell name="quill"> <kit-boundary surface="note-editor" feature="notes" entity-type="note" entity-id="42"> <kit-button meta-event="note.saved" meta-command="notification.show" meta-prop-message="Saved" meta-prop-tone="success" > Save </kit-button> </kit-boundary></kit-shell>When the user clicks Save:
Stage 1 — Interaction
Section titled “Stage 1 — Interaction”The user clicks <kit-button>. The native <button> inside fires a click. The click bubbles. Because click events are composed, they cross shadow boundaries.
Stage 2 — Meaning
Section titled “Stage 2 — Meaning”The <kit-boundary> parent has a delegated click listener. It walks the click’s composedPath() looking for the nearest element with metadata. It finds <kit-button> and reads:
meta-event="note.saved"meta-command="notification.show"meta-prop-message="Saved"(becomes payload{ message: "Saved" })meta-prop-tone="success"(adds payload{ tone: "success" })
Stage 3 — Context
Section titled “Stage 3 — Context”The boundary computes its context by walking up through any parent boundaries:
{ surface: 'note-editor', feature: 'notes', entity: { type: 'note', id: '42' }}If there were nested boundaries, surfaces would be an ordered list.
Stage 4 — Runtime
Section titled “Stage 4 — Runtime”The boundary calls the shell’s runtime:
runtime.emit({ type: 'note.saved', context: { surface: 'note-editor', feature: 'notes', entity: { type: 'note', id: '42' } }, payload: { message: 'Saved', tone: 'success' },})
runtime.command({ type: 'notification.show', context: { surface: 'note-editor', feature: 'notes', entity: { type: 'note', id: '42' } }, payload: { message: 'Saved', tone: 'success' },})The runtime stamps each one with id (UUID) and timestamp, and emits diagnostics.
Stage 5 — Modules
Section titled “Stage 5 — Modules”Every module subscribed to note.saved observes the event. Every module subscribed to * observes the event. The command handler registered for notification.show runs and (if a <kit-toast-region> exists) appends a toast.
Suppose the shell installed three modules:
shell.modules = [ notificationModule(), // handles notification.show auditModule, // observes note.saved, writes to localStorage analyticsModule, // observes *, batches sends to PostHog]After one click:
- A toast appears.
- An audit entry is appended.
- An analytics event is queued.
- The button has no idea any of this happened.
Why this shape, not direct calls
Section titled “Why this shape, not direct calls”You could argue: just give the button onSave={() => { showToast(); track(); audit() }}. Why the indirection?
Three reasons:
Modules are observed, not imported. The button doesn’t know which modules exist. You can add or remove them without touching UI. Swap analytics vendors by swapping a single module file. Disable audit in dev by not installing it.
Capabilities compose without coupling. Five product systems can react to one event without conspiring. The audit module doesn’t know analytics exists. The notification module doesn’t know audit exists. Adding observability is one new module; nothing else changes.
Context is structural, not stuffed into props. The button doesn’t pass surface, feature, or entity to anything. The DOM tree carries them. If you move the button to a different boundary, its context changes automatically.
Events vs commands — why both
Section titled “Events vs commands — why both”Events are facts: X happened. Commands are requests: please do Y.
Many modules can observe one event. Exactly one handler runs for one command. Commands return a result. Events don’t.
You usually want events for what just happened (analytics, audit, observability) and commands for make something happen now (open a dialog, show a toast, save a draft, validate a form).
The same interaction can declare both. Kitsune emits the event first, then dispatches the command — so observers see the fact before the imperative behavior runs.
What you do not see in the runtime
Section titled “What you do not see in the runtime”The runtime is small on purpose. There is no:
- State container
- Router
- Data-fetching abstraction
- Permission system
- Theme manager
Each of those, if you want it, is a module. The runtime is the substrate they install onto.
The runtime in code
Section titled “The runtime in code”The whole shape:
type KitRuntime = { emit(event: KitEventInput): KitEvent on(eventType: string, handler: EventHandler): Unsubscribe
command<T>(command: KitCommandInput): Promise<CommandResult<T>> handleCommand<T>(commandType: string, handler: CommandHandler<T>): Unsubscribe
install(module: KitModule): Promise<void> start(): Promise<void> stop(): Promise<void>
provide<T>(token: ProviderToken<T>, value: T): void inject<T>(token: ProviderToken<T>): T | undefined
createBoundary(options: BoundaryOptions): BoundaryHandle onDiagnostic(handler: DiagnosticHandler): Unsubscribe}Eleven methods. The full runtime is around 250 lines of TypeScript. Read it here.
Read next
Section titled “Read next”- Boundaries — the context graph in detail
- Events and Commands — the protocol
- Modules — how to author one
- The Metadata Protocol — how DOM meaning becomes runtime input