Skip to content

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 element
2. 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 command
5. Modules observe events or handle commands

Then the loop continues, because module behavior typically updates state, which updates the UI, which becomes the next interaction.

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:

The user clicks <kit-button>. The native <button> inside fires a click. The click bubbles. Because click events are composed, they cross shadow boundaries.

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

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.

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.

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.

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

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