@niscorp/nova
v0.2.2
Published
Declarative UI framework for AI agents — JSON layouts, shell orchestration, React
Readme
@niscorp/nova
Declarative, framework-agnostic UI runtime for actions composed from JSON layouts and effects.
Status: on npm and live. The version is below 1.0; releases are compatible all the same — an app that works on one works on the next, and what should go is deprecated, not removed.
Install
pnpm add @niscorp/nova @niscorp/prism @niscorp/strata zod@niscorp/prism, @niscorp/strata and zod are required peers. Everything
else is optional and installed per surface: react for /adapters/react
and /adapters/ink, vue for /adapters/vue, ink and ink-text-input for
/adapters/ink, @niscorp/cortex for /agent. Node >=22.12.
One tree, five renderers
Nova core renders layouts to plain RenderNode[] — no framework, no DOM,
no terminal. What consumes that tree is an adapter, and five ship here:
| adapter | surface | interaction |
| ---------------- | ------------------------- | ---------------------------------------------------------- |
| adapters/react | browser, your styled kit | pointer + keyboard |
| adapters/vue | browser, your styled kit | pointer + keyboard |
| adapters/dom | browser, zero framework | pointer + keyboard |
| adapters/tty | any terminal, line REPL | every interactive carries a [n] marker — type the number |
| adapters/ink | any terminal, full-screen | the same [n] markers, plus focus and live typing |
Same trees down, same ui:click / ui:model / ui:key events up — an app
renders in all five without changing a line, and a served host (see
@niscorp/moss) cannot tell them apart. The TTY numbering is computed once
and shared: [7] is the same interactive in the REPL and the TUI.
An adapter is a walker plus a component kit of ~15 domain-blind primitives. ADAPTER.md is the whole contract; the terminal adapters' APIs are in TERMINAL_DOCS.md; a Svelte or react-native adapter is a sibling folder, built the way the Vue one is.
Components are decoupled from the shell — they receive only the layout's
props plus children / novaModel, and emit events via context hooks.
The React adapter additionally ships <NovaRenderProvider>,
<NovaShellProvider>, <RenderTree>, and the shell hooks; see
REACT_DOCS.md.
An optional slotWrapper prop wraps every action instance's content at
its mount/unmount seam — one pluggable point for animation, auth / feature
gates, logging, or error boundaries, with nova owning none of that logic.
See REACT_DOCS.md.
What it is
Nova is a runtime for actions: stateful units of work backed by data,
endpoints, triggers, lifecycle hooks, and a JSON layout. A Shell hosts
a stack of action instances per canvas (a logical surface — a screen,
a panel, a modal stack), drives their lifecycle, and routes events and
messages between them.
The core is pure TypeScript with zero framework dependencies. The
renderer produces a RenderNode[] tree — a plain JSON-shaped structure
that any framework adapter (React, Vue, headless test harness) can turn
into real elements. Layout, action, and shell logic are all testable
without ever importing a UI framework.
What makes it different from "just another UI library":
- Declarative. Layouts and actions are JSON. The runtime walks them.
- Headless core. No framework lock-in. Adapters are thin.
- Two-way binding built into the layout DSL via
model: "$.path". - Strict / lax modes that decide whether errors throw or are
surfaced via
onError.
What it isn't
Explicitly out of scope right now:
- No LLM features in the core. No prompt scaffolding, no plan generation.
The one agent nova ships — the layout agent — is opt-in at
/agent(see below). - No JSON Schema generation / catalog. Use
z.toJSONSchema()on the exported Zod schemas if you need JSON Schema externally. - No mandatory components. The component registry is empty by
default; opt-in reference kits ship at the adapter subpaths
(
/adapters/react/components,/adapters/vue/components,/adapters/dom/components,/adapters/tty/components,/adapters/ink). - No page around the screen. Nova draws a shell to markup where there is
no browser (the React hooks, Vue's server renderer, the DOM adapter's
renderToString) and picks that markup up in the page — but the document, the paths and the checks a build makes are a host's (@niscorp/cli'snisc export, or moss for a served shell). See the React compatibility section below. - No wire protocol. Definitions are in-process objects, not a
serialization format. The wire lives in moss; nova ships the render
surface a remote terminal targets (
RenderApi, flattened trees that keepActionSlotidentity).
Quick example
import { createShell, createLayoutStore, type ActionDefinition } from '@niscorp/nova';
import { defaultRegistry } from '@niscorp/nova/adapters/tty/components';
const counter: ActionDefinition = {
id: 'counter',
data: { count: 0 },
triggers: [
{
event: 'ui:click',
ref: 'inc',
do: [{ increment: 'count' }],
},
],
layout: {
component: 'Box',
children: [
{ component: 'Text', children: 'Count: {{$.count}}' },
{ component: 'Button', ref: 'inc', children: '+1' },
],
},
};
const shell = createShell({
canvases: [{ id: 'main' }],
registry: defaultRegistry(),
layoutStore: createLayoutStore(),
actions: { counter },
});
const id = shell.push('main', 'counter');
const runtime = shell.getRuntime(id);
shell.dispatch({ type: 'ui:click', ref: 'inc' });
console.dir(runtime?.render(), { depth: null });It prints the RenderNode[] tree, after the press:
[
{
type: 'component',
name: 'Box',
props: {},
children: [
{
type: 'component',
name: 'Text',
props: {},
children: [ { type: 'text', value: 'Count: 1' } ]
},
{
type: 'component',
name: 'Button',
props: {},
children: [ { type: 'text', value: '+1' } ],
ref: 'inc'
}
]
}
]There is no React in that example, and no browser. The registry is empty
until a kit is registered, and a layout that names a component it does not
hold renders an error node (COMPONENT_NOT_FOUND) in its place;
defaultRegistry() is the terminal kit. A framework adapter would consume the
same RenderNode[] and produce real elements.
The three subsystems
Layout
A JSON tree of components, conditionals, loops, refs, and primitives.
renderLayout(node, data) walks the tree, resolves bindings, and emits
RenderNode[]. Bindings come in two forms:
"$.user.name"— bare path, returns the raw value."Hello {{$.user.name}}"— interpolated string.
Conditionals at the value level use { $if, $then, $else }. Layout
nodes also have first-class if / for / ref shapes. See
LayoutNodeSchema.
Action
A stateful unit. An ActionDefinition carries:
data— the initial state record.input— optional JSON Schema of thedatakeys an opener may seed when loading the action (its openable-input contract; descriptive, not enforced by the runtime).endpoints— named HTTP or local-function call configurations, with optionalrequest/responsetransform configs run by the injected evaluator.triggers—(event | message) + ref → do: Step[]bindings.lifecycle—mount,unmount,suspend,resumehooks, each aStep[].layout— the layout to render.
A Step is either a mutation (one of set, toggle, increment,
decrement, push, pop, removeAt, move, clear, reset) or an
effect (call, emit, navigation push/pop/replace/popTo/resetTo/
removeInstance/removeSelf/reconcile, reload).
Mutations are op-per-file under action/mutations/ops/.
Every navigation step moves ONE action. reconcile is the one that moves as
many as data says: it makes a canvas hold exactly the actions a list in the
action's data names — missing ones pushed, unlisted ones removed, the rest kept
mounted (shell/reconcile.ts, as a step). A row naming an action the shell
does not have is skipped, as an ungranted initial candidate is.
{ reconcile: { canvas: 'tools', to: '$.tools', action: 'tool_id', own: 'canvas' } }action names the row field holding the action id; input (optional) the field
holding its input. own: 'pushed' (default) removes only what this action placed
there — stamped with its definition id — while 'canvas' treats the whole canvas
as the action's. Added at grammar nisc.nova 1: a reader at 0 refuses an action
that uses it (TOO_NEW).
The runtime is a closure factory (createActionRuntime) — no classes.
It owns a reactive data store, an AbortController, the trigger
handles, and the model-binding listeners.
An ActionFragment is a reusable partial action (every field optional, a
kind: 'fragment' marker) — layout chrome plus wired triggers/data. It is
composed into a concrete action at the call site via a push/replace
with: ['id']: the fragment wraps the action, dropping the action's layout
into its { slot: 'body' }; the action wins on conflict. Pure data, so it
ships in a DB row and can be referenced rather than inlined.
Shell
The orchestrator. createShell(config) validates every action
definition at construction (boundary Zod validation) and returns a
Shell with push, pop, popTo, removeInstance, replace, clear,
back, originOf, registerAction, removeAction, registerFragment,
addCanvas, removeCanvas, setCanvasLayout, setLayout, setPhrases,
getPhrases, getCanvasState, getRuntime, getState,
getShellRenderTree, getCanvasRenderTree, flattenRenderTree,
dispatch, publish, onStateChange, onDataChange, onEndpoint,
onCanvasChange, dispose. Canvases are CanvasConfigs ({ id, mode?,
actionLayout?, initial? }) — a canvas can pre-seed its stack, declare how
its instances are arranged, and hold them as a stack (default: the top is
active, the rest suspended) or a list (every instance stays live). The shell maintains a stack per
canvas, drives lifecycle hooks via the runtime, composes any with
fragments into the action at push/replace time, and routes navigation
effects emitted from action steps back into shell calls.
The head
nova:head is the document's <head>, as a node in a layout. Its children
are the elements a head holds, named as HTML names them, and bound to the
action's data like anything else in the layout:
{ component: 'nova:head', children: [
{ component: 'nova:title', children: '{{$.page.title}} · the site' },
{ component: 'nova:meta', props: { name: 'description', content: '$.page.lead' } },
{ component: 'nova:meta', props: { property: 'og:image:alt', content: '$.page.title' } },
{ component: 'nova:link', props: { rel: 'alternate', hreflang: 'de', href: '$.page.german' } },
{ component: 'nova:script', props: { type: 'application/ld+json', data: '$.page.about' } },
]}A child's props are that element's attributes, written as given. Nova
keeps no list of them: any name, property or rel there is — or will be —
can be said without nova learning it. nova:title's text is its children;
nova:script takes a type and its data (any JSON). Loops and conditions
work in a head as they do anywhere in a layout.
Nothing in it is drawn on the screen. No adapter builds an element for a
head or for what it holds, no registry has to have the names, and a component
is never handed a head as a child. It rides in the render tree, so it reaches
every place a screen does — a shell in the page, a snapshot a server drew, the
trees on a wire — and headOf(api) reads it off any of them:
{ elements, actions, refused }.
A screen can hold more than one head: the shell's chrome says what holds on
every screen, an action says its own, a dialog opens over a page. They are
read in the order the screen is drawn, and an element that says the same thing
as an earlier one — the title, a <meta> of that name or property, the
canonical address — takes its place. Anything else stands beside its like. An
element whose value is not answered yet (no content, no href, no text) is
left out.
What a head does not hold is what runs or styles: a script a browser would
execute (nova:script is a data block — a JSON type, never a src), a
stylesheet, http-equiv, a handler attribute. A layout is data, and data that
reaches every page must not be able to run. Such an element is left out and
named in refused. Those belong in the app's own index.html, which holds
anything, or in the kit component that needs them.
@niscorp/nova/document is the surface that has a <head>. placeHead
writes the elements into an HTML document being drawn to a string (what
@niscorp/cli and moss's renderDocument call): an element takes the place
of the tag that said the same thing, and anything else is added. In the page,
the DOM, React and Vue adapters keep document.head on the screen
(createHeadKeeper): its elements go in, the document's own tags give way to
them and come back when the screen stops saying them. To offer the names to a
layout agent, register them with HEAD_META so the palette lists them.
Authoring
The Zod schemas are the source of truth and are validated at every boundary. They are exported from the package root:
LayoutNodeSchema,ComponentNodeSchema,ConditionalNodeSchema,LoopNodeSchema,LayoutRefNodeSchema,SlotNodeSchemaActionDefinitionSchema,ActionFragmentSchema,MutationSchema,StepSchema,EffectSchema,TriggerConfigSchema,EndpointConfigSchema,LifecycleConfigSchema
Boundary throws use DefinitionValidationError with a structured
failures array.
Errors and strict mode
ShellConfig accepts strict?: boolean and onError?: (err) => void.
- Lax mode (default). Lifecycle failures route through
onError. Renderer subtree failures becomeRenderErrorNodes and siblings continue. - Strict mode. Lifecycle failures rethrow as
LifecycleError. Shell methods stay synchronous, so the error is stored in a one-slot buffer and surfaces at the next public shell call (push,pop,replace,clear,getCanvasState). Renderer failures throw out ofrender().
The error hierarchy lives in shared/errors.ts:
NovaError
├── RenderError
├── ComponentNotFoundError
├── LayoutRefNotFoundError
├── DefinitionValidationError
├── UnknownActionError
├── UnknownFragmentError
├── UnknownFunctionError
├── ShellDisposedError
├── LifecycleError
└── MutationErrorAll carry a stable code (ErrorCodes.*), an optional context, and
a JS-native cause.
Two-way binding
A layout component can declare model: "$.user.name". The renderer
emits model: { ref, path: 'user.name' } on the corresponding
RenderComponentNode. The action runtime auto-installs a ui:model
event listener for that ref. The framework adapter is responsible
for emitting ui:model events on the event bus when its inputs
change; the runtime responds by applying a set mutation to the
configured path.
model paths inside loops are resolved to absolute paths
(items.0.value) by the renderer using its scope-path tracker, so
loop items round-trip correctly.
Async safety
Every StepContext carries an AbortSignal from a runtime-owned
AbortController. callEndpoint forwards the signal to fetch. On
unmount, the controller is aborted, so in-flight calls bail out
without leaks. Unmount hooks themselves run on a fresh signal so they
can perform their own final async work (e.g. telemetry flush).
React compatibility
- React 19 required (peer
react ^19.2.4). The adapter usesuseSyncExternalStore. - Concurrent rendering: fully supported.
useSyncExternalStoreis tearing-safe by design — the snapshot getters inuseRenderTreeanduseCanvasreturn referentially-stable values when the underlying data or canvas hasn't changed, which the reference-stability tests verify. - StrictMode: supported. Snapshot caches correctly handle effect double-invocation; nothing tears and no "snapshot returned different values" warning appears.
- Suspense: not used as a loading model. Loading state is explicit
data on the action (e.g.
{loading: true}as a regular field), so the layout renderer reacts to it through data bindings. Consumers can still wrap nova components in<Suspense>but it never activates — nova hooks never throw promises. This is intentional: actions model their async state as data so layouts can bind to it directly, rather than unwinding the tree for Suspense to catch. - SSR: supported both ways a screen can be held. A shell that lives with
its adapter —
<NovaShell>and the shell-backed hooks — draws underrenderToString(each hook's server snapshot is the shell's own value) and is adopted byhydrateRootover a second boot of the same shell: awaitshellSettled(shell)before either, so both draw the whole first screen. A served screen —NovaRenderProvider+RenderTreeover aRenderApi— renders with no store at all; that is how moss draws a page server-side and how its React target adopts it.shellView(shell)reads a local shell as that sameRenderApi, which is how the DOM adapter draws one (mountShell, andrenderToStringfrom/adapters/dom/server). The DOM adapter replaces drawn markup on its first render and patches from then on: what did not change keeps its element (ADAPTER.md, "The DOM adapter keeps what did not change" — a DOM kit's contract is there). - React Server Components: not tested. The hooks are client-only.
Reflection and devtools
@niscorp/nova/reflect— read-only introspection over definitions and live shells; pure and framework-free.walkNodes/componentsOf/refsOf/loopVarsOf(layout walks),snapshotShell/describeInstance(running state),actionGraph(emit/message wiring),classifyAudit/auditCatalog(closure-audit triage),livenessOf(what an action can still do once drawn: gestures, the channels it waits on, every endpoint and whether it is called on open or later).@niscorp/nova/devtools— a shell inspector built as pure nova:devtoolsActions(devtools.dock,devtools.inspect— plain ActionDefinitions over generic primitives),DEVTOOLS_CANVAS, andcreateDevtoolsFunctionsserving thedevtools.*fns (the endpoint timeline is a ring buffer fed byshell.onEndpoint). An app opts in by grantingdevtools.*to a dev role, adding the canvas plus a frame slot, and spreading the fns into itsfunctions(session).
Agent and i18n
@niscorp/nova/agent— the layout-authoring surface:layoutAgent(a@niscorp/cortexagent definition — intent + component palette + data example in, aLayoutNodeout, validated againstLayoutNodeSchema),paletteFromRegistry(the palette, read off a component registry'smeta), andcollectInteractive(a layout's refs, models and bound data keys, derived by walking the tree). Needs the optional peer@niscorp/cortex.@niscorp/nova/i18n— nova is language-blind: a host hands the shell a phrasebook (phrasesonShellConfig,shell.setPhrasesat runtime) and nova swaps prose at render. The subpath holds the pieces around that:translateRenderTree,fillPhrase, and the harvest (harvestLayout,harvestDefinition,harvestDefinitions,missingFrom) that lists the phrases a dictionary must cover. See I18N_DOCS.md.
The grammar and its versions
nova's documents — actions, fragments, layouts — are a grammar with a version,
published at @niscorp/nova/migrations for strata:
NOVA_SEQUENCE— the grammar sequencenisc.nova: its kinds (nisc.nova/action,/fragment,/layout), where documents nest inside them (a layout'schildren,children[],then,else,do; an action'slayoutand its endpoints'request/response, which are Prism configs), and its migrations. Stored and submitted documents carry the stamp they were written at and are upgraded where they are read (moss does this for integration actions).NOVA_SCHEMAS— the Zod schema behind each kind, snapshotted by the repo's grammar gate (pnpm check:grammars).
Changing a schema here is a migration (AGENTS.md rule 18): an empty marker
appended to NOVA_SEQUENCE for an addition, document steps — a Prism config
over one node — for anything else. The gate refuses the change until it has
one, and checks every captured lab-app document still upgrades and parses.
Examples
@niscorp/nova/examples exports the reference as data. NOVA_EXAMPLES is a list of small apps, each with what is done to it and what it must then say; NOVA_EXAMPLE_GROUPS names the groups they come in (layouts, actions, endpoints, composition, shells, i18n), in reading order.
import { NOVA_EXAMPLES } from '@niscorp/nova/examples';
const counter = NOVA_EXAMPLES.find((example) => example.id === 'counter');
// counter.action is an ActionDefinition: mount it in a shell and draw it with your own kit
// counter.presses: [{ ref: 'more' }, { ref: 'more' }, { ref: 'fewer' }]
// counter.expected: { data: { count: 1 }, says: ['Count: 1', 'One more', 'One fewer'] }An example is one action alone on a canvas (action) or a small shell (shell: canvases, actions, and how the canvases are arranged). Beside either stand what it needs from its host: fragments, stored layouts, a phrase book (phrases, phraseKeys), and what its endpoints are answered (replies for fn:, fetches for a URL). Every id an example brings begins with the example's own, so a host can hold all of them in one shell.
The examples name only the plain components every kit has (Stack, Text, Button, Input) and nova's two slots, with no prop about looks. One that says stage: false (the ones on i18n) cannot simply be mounted beside a host's own screens, and is shown as what it comes to. The package's tests run each one, so what they say is what this version does.
Building / dev
pnpm build # tsup ESM + CJS + DTS
pnpm test # vitest run
pnpm typecheck # tsc --noEmitThe repo's root tsconfig.json, which this package's tsconfig.json
extends, carries an ignoreDeprecations workaround for tsup's DTS bundler
injecting baseUrl. This is intentional and expected.
