Nova · SHELL_DOCS.md

This is SHELL_DOCS.md as @niscorp/nova 0.5.1 ships it. A coding agent reads it from node_modules. The file itself:

nisc.dev/nova/SHELL_DOCS.md

Shell — Author Guide

How to wire nova together. The shell is the top-level orchestrator: it owns canvases, mounts and unmounts action instances, drives navigation, routes events and messages, and exposes the runtime tree to consumers.

If layouts are the "what" and actions are the "behavior," the shell is the "where and when." This guide is for using the shell. For the architecture see DESIGN.md.


A first shell

import { createShell, createComponentRegistry, createLayoutStore } from '@niscorp/nova';
import { registerNovaReactComponents } from '@niscorp/nova/adapters/react/components';
import { menuAction, settingsAction, profileAction } from './actions';

const registry = createComponentRegistry();
registerNovaReactComponents(registry);
const layoutStore = createLayoutStore();

const shell = createShell({
  canvases: [{ id: 'main' }],
  registry,
  layoutStore,
  actions: {
    menu: menuAction,
    settings: settingsAction,
    profile: profileAction,
  },
  onError: (err) => console.error(err),
});

shell.push('main', 'menu');

shell.push mounts an action instance on a canvas. The shell drives the lifecycle from there. To render the result via React, see REACT_DOCS.md.


What's in ShellConfig

type ShellConfig = {
  // Required
  canvases: CanvasConfig[];              // the canvases the shell hosts (see below)
  actions: Record<string, ActionDefinition>;  // every action the shell knows about

  // Layout of the shell itself
  canvasLayout?: LayoutNode | string;    // how the shell arranges its canvases; when omitted,
                                         // canvases render in a single flex row in declaration order.
                                         // A string is the id of a layout in the layout store

  // Registry / layouts (both built fresh when omitted)
  registry?: ComponentRegistry;          // shared component registry for all actions
  components?: Record<string, RegistrationInput>;  // convenience: registerAll'd onto the registry
  layoutStore?: LayoutStore;             // shared layout store for all actions

  // Fragments
  fragments?: Record<string, ActionFragment>;  // partial actions composed via a push/replace `with: [...]`

  // Optional integrations
  fetch?: FetchFn;                       // fetch implementation for endpoint calls
  transform?: TransformFn;               // evaluator for endpoint `request`/`response` configs (Prism etc.)
  functions?: Record<string, FunctionHandler>;  // handlers for `{ fn: '<name>' }` endpoints
  reconcilers?: Record<string, Reconciler>;  // what reconciles a canvas that names one (see reconcileCanvas below)
  endpointTimeoutMs?: number;            // how long an endpoint call waits for its reply before it
                                         // fails to `onError` (default 30000); an endpoint's own
                                         // `timeoutMs` wins

  // Language (see I18N_DOCS.md)
  phrases?: Phrasebook;                  // the book every tree this shell renders is translated through
  phraseKeys?: PhraseKeys;               // which prop keys carry prose
  onPhraseMiss?: (phrase: string, where: string) => void;  // a phrase with no entry in the book

  // Telemetry / observability
  telemetry?: ShellTelemetry;            // onStateChange, onDataChange, onEndpoint callbacks
  onError?: (error: NovaError) => void;  // single error handler for the whole shell

  // Strictness
  strict?: boolean;                      // throw vs route through onError (default false)

  // Navigation history
  historyDepth?: number;                 // how many navigations `back` can walk (default 50; 0 = off)

  // Test injection / advanced
  shellIdFn?: IdFactory;
  instanceIdFn?: IdFactory;
  eventBus?: EventBus;
  messageBus?: MessageBus;
};

canvases

The canvases the shell hosts. A canvas is a stack of action instances; the topmost is the active one. Each entry is a CanvasConfig:

type CanvasConfig = {
  id: string;                            // arbitrary name, by purpose
  mode?: 'stack' | 'list';               // 'stack' (default): only the top is active, the rest suspended.
                                         // 'list': every instance stays live — author an `actionLayout`
                                         // that loops `$.instances`
  actionLayout?: LayoutNode | string;    // how this canvas arranges its instances; when omitted,
                                         // only the top-of-stack (card-deck) action renders.
                                         // Scope available to resolvables: { instances, active, count }.
                                         // A string is the id of a layout in the layout store
  initial?: CanvasInitialSeed | CanvasInitialSeed[];  // pre-populate the stack on shell creation;
                                         // a list is pushed left to right
  reconcile?: string;                    // the name, in ShellConfig.reconcilers, of what reconciles
                                         // this canvas; without it, reconcileCanvas does
};

// A seed is an action id, or an object with input and fragment composition.
// It is pushed as shell.push(canvasId, ..., { history: false }) pushes it:
// `back` does not undo it.
type CanvasInitialSeed = string | { action: string; input?: Record<string, unknown>; with?: string[] };
canvases: [{ id: 'main' }]                                       // single-pane app
canvases: [{ id: 'nav' }, { id: 'content' }]                     // sidebar + main pane
canvases: [{ id: 'main', initial: 'home' }, { id: 'modal' }]     // app + overlay, home pre-pushed

The shell tracks each canvas independently — pushing on main doesn't affect modal.

On a mode: 'list' canvas a push adds an instance without suspending the others, and removeInstance / removeSelf resume nothing; pop and popTo behave as on a stack — the instance they expose re-runs its mount and resume hooks. An instance closes itself with a { removeSelf: true } step and re-reads itself with { reload: true }. addCanvas applies mode the same way; a canvas removed and re-added under the same id takes the mode it is re-added with.

Each entry of the instances scope an actionLayout sees is the ActionInstance (id, definitionId, canvasId, status, data) plus title — the action's title resolved against the instance's data, falling back to its name, then its id.

In nova itself every initial seed is pushed, and one naming an unknown action throws UnknownActionError. Under moss the same field is read as a candidate list: the first seed the principal is granted is the one that mounts (see moss's docs).

actions

Every action the shell can mount, keyed by id. Actions are validated at shell init via ActionDefinitionSchema. Any malformed definition throws DefinitionValidationError with a list of Zod issues.

Actions can {push: {action: 'someId'}} other actions — but only ones that exist in this map. Trying to push an unknown action throws UnknownActionError.

registry and layoutStore

These are shared across every action in the shell. All actions read components from the same registry and layout refs from the same store. Both are optional — createShell builds fresh empty ones when omitted, and exposes whichever ended up in use as shell.registry / shell.layoutStore. A components map, if provided, is registerAll'd onto the registry either way.

fragments

Reusable partial actions (ActionFragments), keyed by id, referenceable from a push/replace effect's with: [...]. Each is validated at construction, as an action is. The shell keeps them in the registry its actions are in, so an id that is also an action's is refused (DefinitionValidationError). More are added while the shell runs with shell.registerAction, and one is taken out with shell.removeAction.

fetch, transform, and functions

These are dependency injection points. HTTP endpoint calls use fetch. Endpoint request/response configs run through transform — an opaque (config, source) => unknown evaluator nova never interprets (the host typically wires Prism's evaluate); declaring a request/response without one is a hard error. { fn: '<name>' } endpoints resolve their handler from functions. There is no default for any of the three: nova does not reach for the global fetch, and an HTTP endpoint called with none injected fails to onError.

endpointTimeoutMs

How long an endpoint call waits for its reply before it fails to onError — default 30000. An endpoint's own timeoutMs wins over it. It bounds the first answer only.

phrases, phraseKeys, onPhraseMiss

The shell's language: the book every tree it renders is translated through, which prop keys carry prose, and a hook for phrases the book lacks. All absent means no i18n, at no cost. shell.setPhrases swaps the book live. See I18N_DOCS.md.

telemetry

telemetry: {
  onStateChange: (snapshot) => { /* whole-shell snapshot */ },
  onDataChange: (event)    => { /* per-instance data update */ },
  onEndpoint: (event)      => { /* per-endpoint-call outcome */ },
}

The first two fire whenever the shell's state changes. The React adapter uses these via useSyncExternalStore to drive re-renders. You can also use them directly to log, persist, or sync state externally.

onEndpoint fires once per completed endpoint call — fn: and HTTP alike — with an EndpointEvent: { name, kind: 'fn' | 'http', ok, status, ms, instanceId, canvasId } (status is 0 for fn calls; aborted calls are not reported). This is the observability seam devtools' timeline reads.

strict

When true, errors throw all the way up through the shell's public methods. Lifecycle errors surface on the next shell call (because they're inherently async). Use strict mode in tests and dev. Use lax mode (false, the default) in production where you want graceful degradation via onError.

onError

A single function that receives every NovaError produced anywhere in the shell. Use it for logging, telemetry, or showing an error UI. It fires for render errors, endpoint failures, lifecycle failures, navigation failures.


The Shell interface

createShell(config) returns a Shell:

type Shell = {
  readonly id: string;
  readonly registry: ComponentRegistry;    // what the shell was created with (or built)
  readonly layoutStore: LayoutStore;

  // Canvas operations
  push: (canvasId: string, actionId: string, input?: object, fragments?: string[], options?: PushOptions) => string;
  originOf: (instanceId: string) => string | undefined;   // the `origin` an instance was pushed with
  pop: (canvasId: string) => void;
  popTo: (canvasId: string, instanceId: string) => void;
  removeInstance: (canvasId: string, instanceId: string) => void;
  replace: (canvasId: string, actionId: string, input?: object, fragments?: string[]) => string;
  clear: (canvasId: string) => void;
  back: () => boolean;                     // undo the last navigation, anywhere on the shell

  // Runtime registration
  registerAction: (definition: ActionDefinition | ActionFragment) => void;
  removeAction: (actionId: string) => void;   // an action: unmounts live instances. A fragment: what was opened with it keeps what it was composed into

  // Canvas set / layout mutation
  addCanvas: (config: CanvasConfig) => void;
  removeCanvas: (canvasId: string) => void;
  setCanvasLayout: (layout: LayoutNode | string) => void;
  setLayout: (refId: string, layout: LayoutNode) => void;   // hot-swap a LayoutRef target

  // Language
  setPhrases: (phrases: Phrasebook | undefined) => void;
  getPhrases: () => Phrasebook | undefined;

  // State queries
  getCanvasState: (canvasId: string) => CanvasState;
  getRuntime: (instanceId: string) => PublicActionRuntime | undefined;
  getState: () => StateSnapshot;

  // Render-tree access (canvas/shell layouts)
  getShellRenderTree: () => RenderNode[];
  getCanvasRenderTree: (canvasId: string) => RenderNode[];
  flattenRenderTree: (tree: RenderNode[]) => RenderNode[];

  // Event/message dispatch
  dispatch: (event: NovaEvent) => void;
  publish: (channel: string, payload?: unknown) => void;

  // Telemetry subscriptions
  onStateChange: (handler) => Unsubscribe;
  onDataChange: (handler) => Unsubscribe;
  onEndpoint: (handler) => Unsubscribe;
  onCanvasChange: (canvasId, handler) => Unsubscribe;

  // Lifecycle
  dispose: () => void;
};

Canvas operations

push(canvasId, actionId, input?, fragments?, options?)

Mount a fresh instance of an action on top of a canvas. On a stack canvas it suspends the previous top (its suspend lifecycle hook fires). Returns the new instance id.

const instanceId = shell.push('main', 'editor', { fileId: 'f_42' });
shell.push('overlay', 'confirm', { id: 'u_42' }, ['modal-frame']);   // composed with a fragment

The optional input is merged into the action's data before mount. The optional fragments names ActionFragments to compose the action with before instantiation — same as a push effect's with: [...]. An unknown action id throws UnknownActionError; an unknown fragment id throws UnknownFragmentError.

The optional options is a PushOptions:

type PushOptions = {
  origin?: string;     // who is placing this — read back with shell.originOf(instanceId)
  history?: boolean;   // default true; false keeps the push out of the navigation journal,
                       // so `back` cannot undo it
  key?: string;        // which row the instance stands for — read back by a reconciler's keyOf
};

originOf(instanceId)

The origin an instance was pushed with, or undefined — for anything pushed without one, seeded by initial, or already unmounted. It is how a caller that places instances (a server-driven layout, an agent) asks whether one is still its own before closing it.

pop(canvasId)

Unmount the top of a canvas. The unmounted action's unmount lifecycle hook fires. The new top (if any) resumes — its mount hook re-runs to refresh its data, then its resume hook fires.

shell.pop('main');

If the canvas is empty, this is a no-op.

popTo(canvasId, instanceId)

Pop a canvas down to a given instance — everything above it unmounts, in stack order. A no-op if the instance isn't in the stack. This is what stack-nav chrome (a breadcrumb, a tab) uses to jump straight to an ancestor.

shell.popTo('main', instanceId);

removeInstance(canvasId, instanceId)

Remove one instance anywhere in a canvas's stack, not just the top. On a list canvas this is a card closing; on a stack, removing the top resumes the one beneath. A no-op if the instance isn't on the canvas.

shell.removeInstance('tray', instanceId);

replace(canvasId, actionId, input?, fragments?)

Replace the top of a canvas with a new action instance. The replaced action unmounts; the new one mounts. The action below stays unaffected.

shell.replace('main', 'next-step', { progress: 0.5 });

Useful for wizards where back-navigation isn't wanted.

clear(canvasId)

Unmount every action on a canvas. Each unmount runs unmount lifecycle hooks in stack order.

shell.clear('main');

back()

Undo the shell's last navigation. Returns false when there was nothing left to undo — the answer a landing screen gives.

shell.back();

The shell keeps a navigation journal: before every push, replace, clear and resetTo — and every reconcile that moved something — it writes down the canvas that is about to change. One entry is one position somebody can be returned to; back restores the newest. An entry whose position is already on screen is passed over, so one call can spend several entries and true means the screen moved.

  • Global and ordered. One journal across every canvas, each entry naming the one canvas it describes — so back walks a person's own moves in the order they made them, and never reverts a canvas that changed underneath them (a delivered notice, an agent's card) while they were somewhere else.
  • What survives. An instance the recorded position still names is kept, with everything it holds. One that is gone comes back derived from the action and input that made it: back re-opens a screen, it does not resurrect the form somebody replaced their way out of.
  • A pop is a back. pop, popTo and removeInstance spend a journal entry instead of adding one — otherwise a person who used the app's own back button would have to press the browser's twice.
  • The floor. Canvas seeds (initial, and any push passing { history: false }) are never recorded, so back cannot empty the screen somebody landed on. A revoked action takes its entries with it: removeAction purges anything that would hand it back, and removeCanvas drops what can no longer be restored. A position that was opened with a fragment taken out since cannot be put back as it was: back skips it and goes to the one before.
  • Depth. ShellConfig.historyDepth (default 50) bounds the journal; historyDepth: 0 switches it off and makes back a no-op.

Nothing about the gesture lives here — a browser's back button, a TUI's Escape key and an app's own control all arrive as this one call. In moss it is protocol-level: see its back client message.

Runtime registration and canvas mutation

The shell starts from createShell's actions / fragments / canvases / canvasLayout and can change all four live:

shell.registerAction(definition);        // add (or replace) an action definition, or a fragment for `with: [...]`
shell.removeAction(actionId);            // an action: unmounts live instances, drops the definition. A fragment: drops it; a later `with` naming it throws
shell.addCanvas({ id: 'aside', initial: 'inspector' });  // appends + seeds; no-op if the id exists
shell.removeCanvas('aside');             // unmounts its instances, then drops it
shell.setCanvasLayout(layout);           // swap how the shell arranges its canvases
shell.setLayout('region-a', layout);     // swap what a LayoutRef placeholder resolves to

removeAction with an id the shell does not have is a no-op. Where it unmounts the top of a canvas, the instance beneath resumes; fragments and the instances of other actions are untouched.

setLayout is the hot-swap hook for dynamic region layouts: a canvasLayout embeds { ref: id } placeholders, and this replaces what one resolves to — the frame/chrome stays intact.

Language

shell.setPhrases(book);                  // replace the book; reaches instances already mounted
shell.setPhrases(undefined);             // back to the source language
shell.getPhrases();                      // the book in force, or undefined

setPhrases fires a state change, so mounted adapters re-render; stacks and instance data are untouched. See I18N_DOCS.md.

reconcileCanvas(shell, canvasId, desired, options)

The declarative verb beside the imperative ones: make a canvas hold exactly a desired list. It is what the reconcile step runs (see ACTION_DOCS.md), exported for a host that produces a whole desired state itself.

import { reconcileCanvas } from '@niscorp/nova';

const { changed, notes } = reconcileCanvas(
  shell,
  'tools',
  [{ actionId: 'notes' }, { actionId: 'timer', input: { minutes: 5 }, with: ['card-frame'] }],
  { origin: 'agent', own: 'pushed' },
);

origin (required) is stamped on everything the call pushes. own: 'pushed' (default) moves only instances carrying that origin; own: 'canvas' moves everything on the canvas. One instance per action id. An optional definitionOf(actionId) enables the re-aim rule: when a live instance's input changed and that input carries a key the action declares in its input and its mount-time load reads through an endpoint request, the instance is re-opened instead of written into. A desired entry's with names the fragments the action is composed with when the call pushes it, as push takes them; its row is the row the reconcile step read it from, and reconcileCanvas does not read it. notes lists what the call did, in order (placed notes, closed timer, set minutes on timer), for a log or a trace. It never reorders: what it keeps stays where it is, and what it places goes on top.

A canvas that names its own reconciler

A canvas with reconcile: 'keyed' is reconciled by ShellConfig.reconcilers.keyed instead, whenever a reconcile step aims at it. A Reconciler is called as reconcileCanvas is, and returns what it does:

type Reconciler = (shell: Shell, canvasId: string, desired: readonly Desired[], options: ReconcilerOptions) => ReconcileResult;

type ReconcilerOptions = ReconcileOptions & {
  moveInstance: (canvasId: string, instanceId: string, index: number) => void;
  keyOf: (instanceId: string) => string | undefined;
};
  • desired is the step's list, less the rows naming an action the shell does not have. Each entry carries its row as the action holds it, so the reconciler reads whatever names a row (a key field) itself; do not change it.
  • options carries the step's origin and own, and definitionOf.
  • moveInstance puts an instance at a position on a canvas (0 is first; an index past either end is that end; an instance that is not there is left alone). It re-mounts nothing — the instance keeps its data, and the adapters keep its element. On a stack canvas, a move that changes the top suspends the old top and resumes the new one. It writes nothing to the navigation journal.
  • keyOf answers the key an instance was pushed with (shell.push(…, { origin, key })), and nothing once it is gone. An instance back re-opens carries none.
  • Return changed: true whenever anything on the canvas changed, moves included: only then is the position before the call kept for back. Everything the reconciler does is one navigation.

A canvas naming a reconciler the shell was not given is refused (DefinitionValidationError) by createShell and by addCanvas. nova ships no keyed reconciler: which rows match, and what a kept one is given, is the app's to say.

Render-tree access

For non-React consumers (evaluators, exporters, tests):

shell.getShellRenderTree();              // the canvasLayout rendered against { canvases }
shell.getCanvasRenderTree('main');       // a canvas's actionLayout rendered against { instances, active, count }
shell.flattenRenderTree(tree);           // materialise a tree: CanvasSlot resolves away, ActionSlot survives

flattenRenderTree resolves CanvasSlot markers into their canvas trees, but the ActionSlot marker survives: a component node with props: { instanceId, canvasId, definitionId }, key: instanceId, and the instance's rendered tree as children. Served (remote) trees keep instance identity this way — a renderer keys by instance so a swap remounts, and a terminal-side slot wrapper has its seam.

React consumers render trees through component boundaries instead — see REACT_DOCS.md.

State queries

getCanvasState(canvasId)

Returns the current state of one canvas.

const state = shell.getCanvasState('main');
// {
//   id: 'main',
//   stack: [ActionInstance, ActionInstance, ...],
//   active: ActionInstance | undefined,
// }

The stack is in mount order (oldest first). active is the topmost (or undefined if empty).

getState()

Returns a snapshot of every canvas at once.

const snapshot = shell.getState();
// { canvases: { main: CanvasState, modal: CanvasState, ... } }

This is what useSyncExternalStore calls under the hood for the React adapter.

getRuntime(instanceId)

Returns the runtime for a specific action instance, or undefined if it's been unmounted. The returned PublicActionRuntime is narrow — it doesn't expose internal lifecycle methods.

const runtime = shell.getRuntime('act-7');
runtime?.getData();          // current data snapshot
runtime?.render();           // current RenderNode tree
runtime?.onDataChange(handler);
runtime?.setData({...});    // tooling escape hatch

See ACTION_DOCS.md for the runtime methods.

Event and message dispatch

dispatch(event)

Send a UI event into the shell. Triggers with a matching event: (and optional ref:) fire.

shell.dispatch({ type: 'ui:click', ref: 'save' });
shell.dispatch({ type: 'ui:input', ref: 'name', payload: 'Ada' });
shell.dispatch({ type: 'ui:model', ref: 'name', payload: 'Ada' });
shell.dispatch({ type: 'ui:key', ref: 'search', key: 'Enter' });

A NovaEvent is { type, ref?, payload?, origin? } — type one of ui:click, ui:submit, ui:input, ui:focus, ui:blur, ui:model, ui:key (which also carries key), ui:drop. origin is an instance id: an event carrying one reaches that instance's triggers only; one without it reaches every instance listening. Adapters stamp it at the instance boundary.

This is how the React adapter's components push events into the shell when buttons get clicked, inputs change, etc. You normally don't call it directly from app code — components do.

publish(channel, payload?)

Send a message on a channel. Triggers with a matching message: fire — across all actions on all canvases.

shell.publish('cart-updated', { itemCount: 3 });
shell.publish('user-logged-out');

Use it to coordinate between actions that don't otherwise know about each other. A listening trigger reads the payload as @event.payload.

The nova:navigated:<canvasId> channel

The shell itself publishes one message per canvas whenever that canvas's active instance changes — whatever caused it (a push, back, a host's call). Chrome that follows a canvas (a sidebar highlight, a breadcrumb) listens to it instead of to the click that sent somebody there.

import { navigatedChannel } from '@niscorp/nova';

navigatedChannel('main');                // → 'nova:navigated:main'
// payload: { canvas: 'main', action?: string, instance?: string }
// `action` / `instance` are absent when the canvas went empty

triggers: [
  { message: 'nova:navigated:main', do: [{ set: 'current', value: '@event.payload.action' }] },
]

Announcements are deferred and coalesced: a burst (a resetTo is a clear and a push) says once where the canvas ended.

Telemetry subscriptions

onStateChange(handler) / onDataChange(handler)

Imperative subscription form (alongside the declarative telemetry config option). Returns an Unsubscribe.

const off = shell.onDataChange((event) => {
  console.log(`${event.instanceId} data:`, event.data);
});
// later:
off();

The React adapter uses these internally; app code can use them to drive logging, persistence, devtools, etc.

onEndpoint(handler)

Fires once per completed endpoint call any action makes — fn: and HTTP alike — with { name, kind, ok, status, ms, instanceId, canvasId } (status 0 for fn; aborted calls not reported). Returns an Unsubscribe. Devtools' endpoint timeline is built on this.

onCanvasChange(canvasId, handler)

Subscribes to one canvas. Fires with the new CanvasState only when the canvas meaningfully changed — stack length, item ids/statuses, or the active instance. The shell owns this equality check, so subscribers (adapters included) never encode what "changed" means.

const off = shell.onCanvasChange('main', (state) => {
  console.log('main active:', state.active?.id);
});

dispose()

Tear down the entire shell. Unmounts every action on every canvas. Detaches all listeners. After dispose(), every shell method becomes a no-op or throws ShellDisposedError.

useEffect(() => {
  const shell = createShell({ ... });
  return () => shell.dispose();
}, []);

Always dispose shells you create — they hold references to runtimes which hold references to event listeners.

shellView(shell), shellSettled(shell) and shellIdle(shell)

Three helpers beside the shell: for anything that draws one, and for anything that presses something on one and reads what came of it.

import { shellView, shellSettled, shellIdle } from '@niscorp/nova';

const { api, subscribe } = shellView(shell);   // the shell, as a RenderApi
api.frame();                                   // the shell layout's tree
api.canvasTree('main');                        // one canvas — [] when nothing visible is mounted
api.dispatch('main', { type: 'ui:click', ref: 'save' });   // to the canvas's active instance
const stop = subscribe(() => redraw());        // once per burst of changes

const whole = await shellSettled(shell, { waitMs: 300 });  // true: nothing is still mounting

api.dispatch('main', { type: 'ui:click', ref: 'save' });
const done = await shellIdle(shell, { waitMs: 5000 });     // true: what the press set going has finished

api.dispatch stamps an event that names no origin with the canvas's active instance; api.publish(channel, payload?) is shell.publish. On a canvas with nothing mounted there is no instance to stamp it with, and the event reaches every instance listening. shellSettled and shellIdle also take stopped?: () => boolean — once it answers true the wait ends with false.

api.canvasTree(canvasId) is canvasTreeOf(shell, canvasId): the canvas's flattened tree, or [] when hasVisibleContent finds nothing to show in it. Empty text, a nova:head node, and a fragment or an ActionSlot marker with nothing to show inside count as nothing; any other component counts, also one with no children, and so does an error node.

shellView is what lets an adapter written against RenderApi (the DOM and TTY adapters, a moss terminal) draw a shell that lives beside it. shellSettled resolves true once no instance is initializing — every mount hook, and what it chained to, has been awaited — and false if waitMs (default 300) runs out first. It is the moment to draw a shell to markup, and the moment for the page's own shell to adopt it.

shellSettled asks about mounts and nothing else. A trigger's steps run detached from the event that fired them, so after a press it can answer true while the call the press made is still out. shellIdle is the wider question: nothing is mounting and no chain the shell started is still running — no trigger's steps, no emit on its way to its listeners (and what they then call), no re-read of an action a pop revealed. It is what a check awaits after dispatch instead of sleeping, and what anything that runs an action for somebody else awaits before it reads the screen. Same options, same default wait, false when the wait runs out.

Two things it does not wait for, because they are not this shell's work: the next body of a read the shell is following (a reactive read under moss answers again when somebody writes — wait for that with shell.onDataChange), and anything outside the shell that an endpoint set going. A call that never answers holds it until the call's own timeout fails it (30s by default; an endpoint's timeoutMs wins), so pass the waitMs you mean.

shellSettled is deliberately not widened to this. A page is read whenever it is asked for, also while a long call is out, and that read should not wait on it.


Multi-canvas patterns

canvases: [{ id: 'nav', initial: 'navigator' }, { id: 'content', initial: 'welcome' }]

// Inside the navigator action:
triggers: [
  { event: 'ui:click', ref: 'home',     do: [{ replace: { action: 'home',     canvas: 'content' } }] },
  { event: 'ui:click', ref: 'settings', do: [{ replace: { action: 'settings', canvas: 'content' } }] },
],

Clicking nav buttons swaps the content canvas without affecting the nav canvas. Each is its own independent stack.

canvases: [{ id: 'main', initial: 'home' }, { id: 'modal' }]

// From the home action:
{ push: { action: 'confirm-delete', canvas: 'modal' } }
// Optionally composed with reusable modal chrome:
{ push: { action: 'confirm-delete', canvas: 'modal', with: ['modal-frame'] } }

// From inside the confirm-delete action:
{ pop: true }   // pops on the action's current canvas, which is 'modal'

The modal canvas is its own stack. The main canvas is unaffected.

Cross-canvas messaging

Actions on different canvases can talk via the message bus:

// Producer canvas — emits when something happens
{ emit: { channel: 'item-added' } }

// Consumer canvas — has a trigger listening for it
triggers: [
  { message: 'item-added', do: [{ increment: 'count' }] },
]

The shell's message bus is shared across all canvases. Cross-canvas communication is just publish/subscribe.


State machine

Each action instance lives in one of four states:

                  push
   [ initializing ] ────► [ active ]
                              │  ▲
                  push other  │  │ pop other
                              ▼  │
                          [ suspended ]
                              │
                              │ pop / clear
                              ▼
                         [ unmounted ]
  • initializing — between push() and the moment the mount lifecycle hook completes.
  • active — the topmost instance on a canvas. Triggers attached, render reflects current data.
  • suspended — another action is on top (on a stack canvas; a list canvas suspends nothing). Triggers stay attached but do not fire (a suspended action reacts to nothing). Async work already in flight is not cancelled — only unmounting aborts it.
  • unmounted — terminal. The runtime is disposed; getRuntime returns undefined.

Lifecycle hooks fire on each transition: mount (init→active), suspend (active→suspended), mount again then resume (suspended→active — the re-run of mount refreshes the data a backgrounded action ignored), unmount (any→unmounted).


ID generation

Every shell has a unique id and assigns ids to action instances. By default both use crypto.randomUUID() if available, falling back to a Math.random-based scheme, prefixed shell- and act-. For deterministic tests, inject your own:

import { createIdFactory } from '@niscorp/nova';

const myInstanceIds = createIdFactory('act');
// or write a closure-based counter for full determinism

createShell({
  ...
  shellIdFn: () => 'test-shell',
  instanceIdFn: myInstanceIds,
});

Errors

The shell can produce these:

  • UnknownActionError — shell.push(canvas, 'foo') where 'foo' isn't in actions
  • UnknownFragmentError — a fragments / with: [...] id that isn't registered
  • ShellDisposedError — push, replace, and the registration / canvas / layout / phrase setters after dispose() (pop, popTo, removeInstance, clear, removeCanvas and back are no-ops instead)
  • DefinitionValidationError — at shell construction, if any action in actions or fragment in fragments fails schema validation, or an id is in both; also from registerAction
  • LifecycleError — a lifecycle hook step threw (in strict mode propagates, in lax mode routes via onError)

Plus all action-level errors (from ACTION_DOCS.md) and layout-level errors (from LAYOUT_DOCS.md) bubble up through the shell's error pipeline.


Quick reference

const shell = createShell({
  canvases: CanvasConfig[],                // { id, mode?, actionLayout?, initial? }
  actions: Record<string, ActionDefinition>,
  canvasLayout?: LayoutNode | string,
  registry?: ComponentRegistry,            // built fresh when omitted
  components?: Record<string, RegistrationInput>,
  layoutStore?: LayoutStore,               // built fresh when omitted
  fragments?: Record<string, ActionFragment>,
  fetch?: FetchFn,
  transform?: TransformFn,
  functions?: Record<string, FunctionHandler>,
  endpointTimeoutMs?: number,              // default 30000
  phrases?: Phrasebook,
  phraseKeys?: PhraseKeys,
  onPhraseMiss?: (phrase, where) => void,
  telemetry?: { onStateChange?, onDataChange?, onEndpoint? },
  strict?: boolean,
  onError?: (error) => void,
  historyDepth?: number,                   // default 50; 0 = off
  shellIdFn?, instanceIdFn?, eventBus?, messageBus?,
});

// Canvas ops
shell.push(canvasId, actionId, input?, fragments?, options?);  // → instanceId; options: { origin?, history? }
shell.originOf(instanceId);                            // → string | undefined
shell.pop(canvasId);
shell.popTo(canvasId, instanceId);
shell.removeInstance(canvasId, instanceId);
shell.replace(canvasId, actionId, input?, fragments?); // → instanceId
shell.clear(canvasId);
shell.back();                                          // → boolean

// Runtime registration / canvas mutation
shell.registerAction(definition);
shell.removeAction(actionId);
shell.addCanvas(config);
shell.removeCanvas(canvasId);
shell.setCanvasLayout(layout);
shell.setLayout(refId, layout);

// Language
shell.setPhrases(phrases);                 // undefined → the source language
shell.getPhrases();                        // → Phrasebook | undefined

// State
shell.getCanvasState(canvasId);            // → CanvasState
shell.getRuntime(instanceId);              // → PublicActionRuntime | undefined
shell.getState();                          // → StateSnapshot

// Render trees (non-React consumers)
shell.getShellRenderTree();                // → RenderNode[]
shell.getCanvasRenderTree(canvasId);       // → RenderNode[]
shell.flattenRenderTree(tree);             // → RenderNode[]

// Events / messages
shell.dispatch({ type, ref?, ... });
shell.publish(channel, payload?);

// Telemetry
shell.onStateChange(handler);              // → Unsubscribe
shell.onDataChange(handler);               // → Unsubscribe
shell.onEndpoint(handler);                 // → Unsubscribe
shell.onCanvasChange(canvasId, handler);   // → Unsubscribe

// Teardown
shell.dispose();

For wiring a shell into a React app, see REACT_DOCS.md. For authoring the actions a shell hosts, see ACTION_DOCS.md.