Runtime Implementation Guide
For runtime implementers, this defines the compilation pipeline and the execution model.
10.1 Compilation Pipeline
[CRDT graph store]
↓ project (selector)
[kumiki source (text view)]
↓ parse
[AST]
↓ name resolution
[resolved AST] ←─── error: undef-ref, dangling
↓ type check
[typed AST] ←─── error: type-mismatch, refinement
↓ effect analysis
[effect-annotated AST] ←── error: cap-missing, direct-call
↓ purity check
[verified AST] ←── error: reducer-side-effect, tile-mutation
↓ lower
[IR (Kumiki Intermediate Representation)]
↓ codegen
[runtime artifacts]:
• signal graph (JS or WASM)
• effect dispatcher table
• episode logger
• dev-tool trace UIEach phase performs an independent check. Errors are returned as the structured errors of AI Editing.
10.2 IR
The intermediate representation is a Typed Dataflow Graph. A node is one of the following:
| Node kind | Role |
|---|---|
slot-read | read from a slot |
slot-write | write to a slot (reducer only) |
field-access, index | record/collection element access |
op, call | operation / function call (including fn-defined functions) |
fn-body | the body of the fn layer (pure computation, depends only on arguments) |
match | union branching |
if, when, for | control |
emit | effect emission |
event-source | the entry point of an event |
dom-node | DOM output node |
dom-bind | slot binding to a DOM node |
Edges are dependencies (dataflow).
10.2.1 IR Serialization Format
Debuggable as JSON; in production, CBOR (binary):
{
"version": "0.1",
"slots": [
{"name": "todos", "type": "...", "init": "...", "hash": "..."},
{"name": "draft", "type": "Text", "init": {"text": ""}, "hash": "..."}
],
"effects": [
{"name": "persist", "cap": "storage.write", "in": "...", "out": "Unit", "policy": "debounce:300"}
],
"reducers": [
{
"name": "addTodo",
"on": {"kind": "ui.submit", "selector": {"tile": "NewTodoForm"}},
"do": [
{"op": "let", "name": "id", "value": {"op": "call", "fn": "TodoId.fresh"}},
{"op": "slot-write", "lhs": {"slot": "todos", "key": {"var": "id"}}, "rhs": "..."},
{"op": "slot-write", "lhs": {"slot": "draft"}, "rhs": {"text": ""}},
{"op": "emit", "name": "persist", "args": [{"slot-read": "todos"}]}
]
}
],
"tiles": [
{
"name": "App",
"body": {"kind": "page", "children": [...]},
"deps": ["slot:todos", "slot:draft", "tile:TodoList", "fn:matchFilter"]
}
],
"fns": [
{
"name": "matchFilter",
"params": [{"name": "t", "type": "Todo"}, {"name": "f", "type": "Filter"}],
"ret": "Bool",
"body": {"op": "match", ...},
"hash": "..."
}
],
"app": {
"name": "TodoApp",
"caps": ["storage.read", "storage.write"],
"routes": {"/": "App", "/404": "NotFound"},
"init": [{"emit": "loadTodos", "args": []}],
"theme": "DefaultTheme"
}
}10.3 Signal Graph
The runtime generates a static signal graph from the IR. It is Solid-style fine-grained reactivity, but in Kumiki the graph structure is fully determined at compile time (no signal tracking at runtime).
10.3.1 Node Kinds
| Node | Input | Output |
|---|---|---|
SlotNode | – | slot value |
ComputeNode | values of upstream nodes | derived value |
BindNode | values of upstream nodes | DOM operation |
EventNode | DOM event | reducer call |
10.3.2 Update Algorithm
on reducer execution:
collect modified slots into Set<SlotId>
for each modified slot:
for each downstream ComputeNode/BindNode (precomputed):
mark dirty
process dirty queue in topological order:
recompute ComputeNode
apply BindNode → DOM mutationBecause dependencies are statically analyzed at compile time, the runtime tracking cost is 0.
10.3.3 batching
All slot changes within a single reducer execution are treated as one batch. Successive changes inside a for loop are also the same batch. After the batch is finalized, the signal graph is updated only once.
10.3.4 Invariants of DOM Rendering
- null/undefined child nodes are skipped. A false branch like
when(false, X)passesnullas a child, butrenderTileignores it and renders only the siblings column/row/card/box/panel/stack/region/scroll/fieldsetare all<div>-based containers.stackis equivalent tocolumn(vertical stack)gridisdisplay: grid+ acolsprop yieldinggrid-template-columns: repeat(N, 1fr)(numeric) or a direct CSS value (string)divideris a standalone<hr>element (no children)- timer reducer fires via
setInterval, and stops viaclearIntervalon the app'sdispose
10.3.5 The bind path of input/textarea/select
You can bind to a nested lvalue path like bind=draft.title. The runtime:
- Display: follows
_live[root][...path]to read the initial value - Change: on an input event, uses
_setPathto immutably update the root slot - Focus restoration: identifies it by putting the full path string (
"draft.title") in thedata-kumiki-bindattribute
10.3.6 Dynamic theme switching
You can specify the theme by slot name, like app theme = themeName. The runtime:
- If
app.themeNamedoes not exist inapp.themes, reads_live[app.themeName]to resolve the theme name - Re-runs
applyThemeDefaultsat the beginning of eachrender()→ changes to the slot value are reflected in the body style
slot themeName : Text = "Light"
theme Light = { colors: {bg: "#fff", fg: "#222"}, ... }
theme Dark = { colors: {bg: "#222", fg: "#eee"}, ... }
reducer toggle on=ui.click(ThemeBtn) do= themeName := if themeName == "Light" then "Dark" else "Light"
app App ... theme = themeName ; ← pass the slot name10.3.7 polymorphic collection methods
.filter / .map / .get-or, etc., are type-dispatched at runtime:
.filter(pred):Array.prototype.filterfor an Array,mapFilterfor an Object.map(fn): element map for an Array; for Option/Result, map over the contents of Some/Ok (mapOver).flat-map(fn): passes the Some/Ok of Option/Result to f, while None/Err passes through (flatMapOption).get-or(default)(Option) /.get-or(key, default)(Map): distinguished by the argument countm.entriesreturns[[k, v], ...], and the lambda of a subsequent list op is automatically destructured to$1=k, $2=v
10.3.8 Value Matching of select
select(value=v, options=[...]) decides the selected state of an option by a structural key:
- A variant is keyed by recursively serializing
_tag+ payload.Some(Backlog)andSome(InProgress)become different keys (with a flat_tagcomparison both would collide as"Some", so including the payload is essential) - You can use a "variant wrapped in a variant" such as
Option(Status)as an option value
10.3.9 Focus Restoration
It maintains the focus and cursor position of an input/textarea being edited even after re-rendering:
- Elements with
bind=: re-identified by thedata-kumiki-bindattribute (a nested path is a full path string) - Elements with
id=: re-identified by id - Neither (e.g. a search box with only
value=): re-identified positionally by a DOM child-index path
10.4 Effect Dispatcher
Responsible for executing the effects emitted from a reducer.
10.4.1 Acceptance
When a reducer completes, the set of emitted effects is passed to the dispatcher:
[{name: "persist", args: {...}, key: <derived>, policy: "debounce:300"}, ...]10.4.2 capability check
Checks whether each effect's cap is included in app.caps. A violation is not executed and is notified to app.error.
10.4.3 policy Handling
| policy | Implementation |
|---|---|
| parallel (default) | immediate dispatch |
latest | cancel the running effect of the same name, start a new one |
latest-per-key(k) | the same, per (effect-name, key) |
queue | execute sequentially in FIFO |
debounce(d) | wait d ms on calls of the same name and execute only the last |
throttle(d) | discard additional calls of the same name within d ms |
once | discard calls with the same in |
10.4.4 retry
When retry=... is specified, retry on an Err result that is a 5xx/network error. Exponential backoff adds ±20% jitter.
10.4.5 Delivery of Results
On effect completion, the result is notified to the runtime as an <effect-name>.ok($value, $key) / <effect-name>.err($error, $key) event. The matching reducer is executed.
10.4.6 Implementation of Standard Capabilities
| capability | Implementation |
|---|---|
http.* | fetch() |
storage.* | window.localStorage |
session.* | window.sessionStorage |
indexed.* | IndexedDB API |
nav.* | History API |
clipboard.* | Clipboard API |
notification.show | built-in tile (toast/confirm/modal) |
analytics.* | hook (implementation injected via app.analytics at app startup) |
log.* | console.* + optional hook |
crypto.* | Web Crypto API |
media.* | MediaDevices API |
geo.* | Geolocation API |
socket.* | WebSocket |
10.5 Episode Loop
The causal sequence derived from a single trigger is recorded as one episode.
10.5.1 Structure of an episode
{
"id": "ep_01JC...",
"trigger": {"kind": "ui.click", "target": "AddBtn", "payload": {...}, "ts": ...},
"steps": [
{"kind": "reducer", "name": "addTodo", "slot-diffs": [...], "emits": ["persist"], "ts": ...},
{"kind": "effect-start", "name": "persist", "args": {...}, "ts": ...},
{"kind": "effect-end", "name": "persist", "result": "ok", "value": "()", "ts": ...},
{"kind": "signal-update", "dirty-slots": ["todos"], "binds-updated": ["TodoList.row.0", ...], "ts": ...}
],
"status": "completed" | "panic" | "cancelled" | "ongoing"
}Reserved trigger.kind values: ui.click, ui.submit, ui.change, ui.input, lifecycle, route.enter, timer, effect.ok, effect.err, init, and ssr.hydrate (the SSR bootstrap, see §10.6.2). ssr.hydrate is asymmetric: the server constructs it during renderToString, ships it to the client as JSON, and the client logger ingests it directly — the client MUST NOT open an ssr.hydrate episode itself via the usual beginTrigger path.
Deferred-policy effect attribution. Effects emitted under policy=debounce(d) complete their setTimeout AFTER the triggering reducer's episode has nominally ended. The dispatcher therefore claims the effect-start step (and its episode-token) at dispatch time, not at the eventual launch, so the deferred effect-end and its .ok / .err reducer chain stay on the originating episode — the causal chain stays whole. A debounce timer that is replaced before it fires records an effect-cancel step (with targetId = <effect-name>) on its originating episode, which then commits as status="completed" with no effect-end. policy=throttle(d) launches synchronously on the leading edge (so the standard sync path attaches effect-start); subsequent dispatches within the window are silently suppressed — the originating reducer's emits list shows the suppressed effect name, but no effect-start follows.
10.5.1.1 Bootstrap episode (SSR hydration)
The server-side renderToString pass collapses the entire app.init causal chain into a single bootstrap episode and ships it inside the SSR snapshot (§10.6.1). Its shape is just an Episode (above) with two additional contracts:
trigger.kind = "ssr.hydrate",trigger.target = <initial-route-path>.stepsmirror the real server-side execution: eachapp.initemit produces a pairedeffect-start/effect-end, the matching{effect, outcome}reducer adds areducerstep (withvolatile-filteredslot-diffs), and a finalsignal-updatelists the non-volatileslots that changed. There is no synthesisedssr.bootstrapstep — the chain stays in the canonical episode grammar so replay tooling works unchanged.
Example:
{
"id": "ep_01JC...",
"trigger": {"kind": "ssr.hydrate", "target": "/", "ts": 1717900000000},
"steps": [
{"kind": "effect-start", "name": "loadUser", "args": {"url": "/api/me"}, "ts": 1717900000001},
{"kind": "effect-end", "name": "loadUser", "result": "ok", "value": {"id": "u_1"}, "ts": 1717900000045},
{"kind": "reducer", "name": "loadUser.ok", "slot-diffs": [{"name": "user", "before": null, "after": {"id": "u_1"}}], "emits": [], "ts": 1717900000046},
{"kind": "signal-update", "dirty-slots": ["user"], "binds-updated": [], "ts": 1717900000047}
],
"status": "completed"
}10.5.2 episode store
- The most recent N in memory (default 100)
- The most recent M in localStorage (default 20, size limit 5MB)
- During development, write to a file with
--episode-log /path/to/log.jsonl
The bootstrap episode (trigger.kind = "ssr.hydrate") is stored on the same path as any other episode: appended to the in-memory ring and (when localStorage mirroring is enabled) persisted on the same eviction policy. No special pinning — once enough later episodes accrue, the bootstrap eventually falls off the tail like any FIFO entry. Hydration runs persistLocalStorage() as part of the ingest so the mirror reflects the bootstrap immediately (an AC for §10.6.2 verification).
10.5.3 replay
kumiki replay <input.kumiki> --from-log <log.jsonl> # replay every episode in the log
kumiki replay <input.kumiki> --from-log <log> <episode-id> # replay one episode by id
kumiki replay <input.kumiki> --from-log <log> \ # repeatable; each entry follows
--mock 'loadUser: from-log' --mock 'persist: ignore' # §8.6's mock grammar
kumiki replay <input.kumiki> --from-log <log> --until-step 5 # stop after the 5th observed step--from-logis currently required. The barekumiki replay <episode-id>form (against the runtime's in-memory store, §10.5.2) needs a long-lived dev-server context and is out of scope for the CLI verb.--mock '<effect>: <spec>'is repeatable.<spec>follows the same grammar asepisode-test.mocks(§8.6):from-log|ignore|ok(<json>)|err(<json>). The payload is parsed as JSON, sook({"id":"u1"})works on the command line as-is.- An effect with no
--mockentry is dropped (matchesepisode-test's default). --until-step Ncounts each observed step (reducer / effect-start / effect-end / signal-update / panic) as one, globally across all replayed episodes, 1-indexed. The slots at the moment of interruption are printed.- Replay synthesises a
signal-updateevent per episode from the slots a reducer actually changed; recordedsignal-updateentries in the input log are not re-played verbatim (they're advisory provenance, not driving input). - Exit code is
0on a clean run,1if any episode panicked or surfaced an unhandled effect error.
10.6 SSR / Edge / Client Split
10.6.1 SSR
- HTML generation renders the tile of the initial route once on the server-side via
renderToString(app, options)from@kumikijs/runtime. - The slot initial values may include the results of the effects emitted in
app.init(not re-executed at hydration). - Response bundle composition:
- HTML (the result of initial tile rendering)
- JSON (the snapshot envelope, structured as below)
- JS (signal graph + effect dispatcher)
The snapshot envelope is versioned and self-describing:
{
"kumiki": 1,
"route": "/posts/abc",
"slots": { "<slot-name>": <value>, ... },
"bootstrap": { /* Episode (§10.5.1), trigger.kind = "ssr.hydrate" */ },
"renderedAt": 1717900000000
}kumikiis the snapshot schema version (current =1). A client whose runtime expects a different version MUST discard the snapshot and fall back to a full CSR boot — this keeps server / client out-of-sync deploys safe.slotsexcludes every slot whose declaration carries thevolatilemodifier (§5 modifiers table): the runtime treats SSR snapshotting as the same serialisation boundary as persistence, sovolatileslots are never written to the wire.bootstrap.steps[].slot-diffsuse the samevolatilefilter, so a volatile slot never appears in either theslotsmap or the bootstrap diff.bootstrap.steps[0..]carry the realapp.initcausal chain (effect-start / effect-end / reducer / signal-update).beforevalues insideslot-diffsare the slot's declared default at the start of the SSR pass;afteris the post-init value mirrored inslots.
10.6.2 Hydration
Hydration runs in a strict, synchronous order. If any step throws, the client discards the snapshot and falls back to a full CSR boot:
- Snapshot load + version check. Parse the snapshot envelope (e.g. from a
<script type="application/json" id="kumiki-state">block). Ifkumiki !== 1, skip steps 2–4 and run a cold CSR boot. - Slot overlay. Write each entry of
snapshot.slotsintoapp.liveBEFORE wiring routing, effects, orapp.start. Volatile slots stay at their declared default — they were never in the snapshot. - Bootstrap ingest. Inject
snapshot.bootstrapinto the episode logger via the dedicatedingestBootstrappath. This is the only legal way for a client to surface anssr.hydrateepisode;beginTriggeris forbidden for that kind. After this step,app.episodes()[0]is the SSR causal chain. - Event handler attach. Attach the runtime's event delegation to the SSR HTML so user input starts dispatching client-side reducers.
app.startfires. The lifecycle reducer fires normally (it never ran on the server).app.initdoes NOT re-fire — the snapshot already carries its results.route.enterfor the current pattern fires afterapp.start, exactly as in a CSR boot.
The observed order on the client is therefore app.episodes() = [bootstrap, app.start episode, route.enter episode?, user-driven episodes...]. The hydration boundary preserves episode continuity — no ssr.hydrate-to-app.start gap and no duplicate init effects.
10.6.3 Edge
SSR on Cloudflare Workers / Vercel Edge, etc.:
- Part of the effect dispatcher (
http.*,storage.kv.*) runs on the edge side - The rest is deferred to the client
- Bundle size budget: runtime 30KB + app code (target)
10.7 Development Server
kumiki dev # start the development server
kumiki dev --port 5173
kumiki dev --episode-log ./eps.log
kumiki dev --strict-a11yFeatures:
- Hot reload (on code change, slots are retained)
- error overlay (detailed display on panic)
- episode timeline panel (visualizes recent episodes)
- inspector (slot values, tile tree, dependency graph)
10.8 Build
kumiki build # production build
kumiki build --target=spa # SPA only
kumiki build --target=ssr # Node.js SSR
kumiki build --target=edge # Edge runtime
kumiki build --target=static # static site
kumiki build --analyze # bundle analysisOutput composition:
dist/
├── index.html
├── assets/
│ ├── app-<hash>.js
│ ├── app-<hash>.css ← reset + theme token expansion only
│ └── icons-<hash>.svg
├── server/ ← only for SSR/Edge
│ └── entry.js
└── manifest.json10.8.1 Vite plugin (@kumikijs/vite)
The build-integration ecosystem seam: drop Kumiki into an existing Vite project (and therefore Next/Astro/SvelteKit/etc.) and import .kumiki files like any module. Each source compiles to an ESM module that default-exports the compiled AppShape (via codegen's exportApp — no auto-mount; the importer owns mounting through mount or defineKumikiElement).
// vite.config.ts
import { kumiki } from "@kumikijs/vite";
export default { plugins: [kumiki()] };import App from "./app.kumiki";
import { mount } from "@kumikijs/runtime";
mount(App, document.getElementById("root"));The module also exports a createApp() factory — import App, { createApp } from "./app.kumiki" — for spinning up multiple independent instances (each createApp() returns an AppShape with its own state).
Options —
bundle(defaulttrue: inline the runtime so each module is self-contained;falseleaves animport "@kumikijs/runtime"for the bundler to dedupe).types(defaultfalse: emit a sibling<name>.kumiki.gen.tsof typedSlots/Providershelpers for type-safe provider authoring; written only when its contents change).Capabilities — a sibling
kumiki.caps.jsonis resolved automatically (same as the CLI), so custom-capability apps compile unchanged.Typing the import — reference the shipped ambient types once so
import App from "./x.kumiki"is typed asAppShape:ts/// <reference types="@kumikijs/vite/client" />
Verified by packages/vite/test/plugin.test.ts; the typed-helper generator (generateDts) by packages/compiler/test/dts.test.ts.
10.9 Runtime API (for Embedding)
When embedding a Kumiki app from a host app:
import { mount } from "kumiki/runtime"
const app = mount({
target: document.getElementById("app"),
bundle: "/assets/app.js",
initialSlots: { /* ... */ },
effectHandlers: {
"analytics.send": (event, props) => myAnalytics.track(event, props)
}
})
app.dispatch({ kind: "ui.click", target: "AddBtn", payload: {} })
app.slots.todos // read-only
app.episodes // recent episodes
app.unmount()10.9.1 Web Component embedding (defineKumikiElement)
The outbound ecosystem seam: wrap a compiled app as a standard custom element so it drops into any host page or framework (React/Vue/Svelte/plain HTML) without a Kumiki-specific integration. It bridges the host both ways and owns the mount lifecycle (mount on connect, dispose on disconnect).
import { defineKumikiElement } from "@kumikijs/runtime";
import { App } from "./my-compiled-app.js"; // the bundle's exported AppShape
defineKumikiElement("my-widget", App, {
// inbound (host → app): host implementations for custom capabilities
providers: { "payments.charge": async (input) => /* … */ },
// outbound (app → host): custom-cap effects surface as DOM CustomEvents
events: ["telemetry.track"],
// declarative props: an observed attribute mapped to a slot
attributeSlots: { "data-count": { slot: "count", parse: Number } },
});<my-widget data-count="3"></my-widget>
<script>
document.querySelector("my-widget")
.addEventListener("telemetry.track", (e) => console.log(e.detail));
</script>- Inbound —
providersare forwarded tomount(same custom-capability seam as Standard Capabilities);attributeSlotsmap observed attributes to slots (applied on connect and on change); imperativeel.setSlot(name, v)/el.setSlots({…})write live slots (refinements enforced) andel.getSlot(name)/el.slotsread them. - Outbound — each capability in
eventsgets a passthrough that dispatchesCustomEvent(cap, { detail: input, bubbles, composed })and resolves ok; aproviders[cap]entry overrides the passthrough for that capability. - Style isolation — by default it renders into the element's light DOM (the runtime's document-level theme/motion styles apply, matching a standalone page). Pass
shadow: trueto render into an open shadow root: the app's motion / theme / state<style>nodes are injected into the shadow root (viamount'sstyleRoot), and theme background/foreground/font are applied to an in-shadow container — so host-page CSS does not bleed in and Kumiki's CSS does not leak out. - Registration is idempotent. For multiple independent instances of the same component, pass the compiled module's
createAppfactory instead of its default export — each element then builds its own state:defineKumikiElement("my-widget", createApp). Passing the defaultAppShapeshares one instance across all elements of that tag.
Verified by packages/runtime/test/element.test.ts.
10.10 Implementation Responsibilities of the Standard Library
For the built-ins enumerated in Standard Library, the runtime implementation guarantees the following behavior:
| Feature | Guarantee |
|---|---|
Map, Set, List | pure (no in-place mutation) |
Option, Result | exhaustiveness check for pattern matching |
Time.now, math.random | callable only inside a reducer, recorded in the episode log |
*.fresh() | generates UUIDv7 |
panic() | puts the episode into the panic state and rolls back slots |
10.11 Performance Budget
| Item | Budget |
|---|---|
| runtime core | ~30KB gzip |
| 1 reducer execution time | < 1ms (typical) |
| signal graph update | < 16ms (60fps) |
| effect dispatch overhead | < 0.1ms |
| episode log write | < 0.5ms (memory) |
To meet these, the runtime is Rust → WASM (optional) or hand-written JS (default).
10.12 Record of Design Decisions
| Decision | Reason |
|---|---|
| signal graph is static | eliminates runtime dependency tracking; performance and predictability |
| batch updates | so that successive changes do not exceed 60fps |
| effects go via the dispatcher | guarantees capability guards and logging structurally |
| episode = per trigger | integrates debugging, testing, and audit into a single unit |
| SSR and CSR consume the same IR | the target difference is only the dispatcher implementation difference |
| runtime 30KB target | practicality on mobile / Edge |
10.13 Next
- Complete examples → examples/