Skip to content

Events and Commands

Events are facts. Commands are requests. They are different shapes of the same idea — something needs to be communicated through the runtime — and using the right one keeps causality readable.

runtime.emit({ type: 'note.saved', payload: { id: '42' } })

An event says: this happened. It has no return value. Many modules may observe it. Each handler runs independently. Failures in one handler don’t affect others.

Common event types:

note.saved
note.deleted
draft.changed
form.validation_failed
dialog.opened
route.changed
session.expired
checkout.started

Notice the past tense (or neutral) phrasing. Events describe state that already changed.

const result = await runtime.command({
type: 'notification.show',
payload: { message: 'Saved', tone: 'success' },
})

A command says: please do this. It has exactly one handler. It returns a result. Failures are caught and reported via the result.

Common command types:

notification.show
dialog.open
dialog.close
draft.save
form.validate
clipboard.copy
route.navigate

Notice the imperative phrasing. Commands ask for behavior.

You could imagine a runtime with only one channel — every message is both an event and a command. Some frameworks do this. The cost is that you can’t tell at a glance whether sending the message is observation or imperative.

Kitsune separates them because:

  • Events have many observers; commands have one handler. Conflating those puts ordering pressure on observation that observation shouldn’t have.
  • Events don’t return values; commands do. A submit handler that needs to know “did the form validate?” is asking a command. A toast pop that needs no answer is observing an event.
  • Events should not have side effects beyond observation. If your “event handler” is doing the work the user expects, it should probably be a command handler.

A useful test: if I removed every observer of this message, should the user-visible behavior still work? Yes → command. No → event.

Many user actions emit a fact and request behavior at the same time. The metadata protocol supports both:

<kit-button
meta-event="settings.saved"
meta-command="dialog.close"
meta-prop-target="settings-dialog"
>
Save
</kit-button>

Click the button. The runtime:

  1. Emits the settings.saved event (audit, analytics, etc. observe it).
  2. Dispatches the dialog.close command (the dialog module closes the dialog).

Event-first ordering means observers see the fact before the command runs. If the command updates state, observers can choose whether to react before or after.

A loose convention that scales:

Events: entity.action_in_past_tense

note.saved
note.deleted
checkout.started
checkout.completed
form.validation_failed
session.timed_out

Commands: domain.imperative

notification.show
notification.dismiss
dialog.open
dialog.close
clipboard.copy
draft.save
form.validate
route.navigate

Use namespaces (note., dialog., route.) so observers can subscribe to whole families:

runtime.on('note.saved', auditNote)
runtime.on('note.deleted', auditNote)
// or
runtime.on('*', everything)

Wildcard subscriptions are a debug convenience; production observers should usually pick specific types.

Both events and commands accept a payload object. Keep payloads:

  • Flat. Avoid nested objects when a flat shape would do.
  • Serializable. Strings, numbers, booleans, arrays, and plain objects only. No DOM nodes, no functions, no class instances. Future devtools and replay tools will assume this.
  • Small. A payload isn’t a database. Reference entities by id; let the module fetch what it needs.

Payloads from DOM metadata are always strings (because attributes are strings). Programmatic payloads can be richer.

Both events and commands carry a context from the surrounding boundary. You don’t pass context manually for DOM-driven interactions — the boundary adds it automatically.

When emitting programmatically, the runtime exposes the current boundary context if you have a handle to it; usually you don’t need to set context manually.

A command’s handler can throw or return a rejected promise. The runtime wraps it:

const result = await runtime.command({ type: 'draft.save', payload: { id: '42' } })
if (!result.ok) {
console.error('save failed:', result.error)
} else {
console.log('saved at', result.value)
}

Event handlers that throw are isolated — one bad handler doesn’t break the rest. The runtime emits a diagnostic so you see the failure.