Specs
A spec is the JSON object that describes an entire UI tree. It names a root element, holds every element in one flat map, and says for each element which component renders it, what props it receives, which children it owns, and when it is visible. The running example streams three specs one character at a time and renders each one as it arrives, so the format on this page is the format you can watch being parsed.
What the demo does
The Run tab is split in two. The left side is the live render output, the right side is the raw JSON as it streams, and the bar along the bottom is a transport: play and pause, a track you can scrub to any character, a character count, and 1x, 2x and 4x speeds.
Three tabs across the top pick the spec. "Heading + Text" is a heading with one text child. "Card + Badge" is a card holding a badge and a paragraph. "Nested Layout" is a heading over two cards, each with its own text child. Picking a tab restarts the stream against that spec, and elements appear as soon as their props arrive. The Text, Heading and Card components draw a skeleton placeholder while they wait; the Badge component has none, so a badge simply appears once its label arrives.
How it is built
The Code tab holds the component and the application config. Open it to read them in place.
The spec the demo streams
The three specs live in specs.ts, beside the component, as plain JSON strings. This is the first one, and it is the whole format in miniature: a root key, a flat elements map, and a children array that holds keys rather than nested objects.
{
"root": "root",
"elements": {
"root": {
"type": "Heading",
"props": { "content": "Welcome to Spec Rendering" },
"children": ["desc"]
},
"desc": {
"type": "Text",
"props": {
"content": "This UI is rendered entirely from a JSON specification. Each element maps to a registered Angular component."
}
}
}
}Nothing in the spec names an Angular class: Heading and Text are registry keys, resolved at render time.
The application configuration
provideRender() registers the render feature. This example passes an empty options object, because the registry and the store travel as inputs on the render surface instead.
import { ApplicationConfig } from '@angular/core';
import { provideRender } from '@threadplane/render';
export const appConfig: ApplicationConfig = {
providers: [
provideRender({}),
],
};The registry names the element types
Every type string in a spec has to resolve to something. defineAngularRegistry() is where the four names the three specs use are bound to components, and signalStateStore() is the store that prop expressions and visibility conditions read from.
protected readonly registry = defineAngularRegistry({
Text: DemoTextComponent,
Heading: DemoHeadingComponent,
Badge: DemoBadgeComponent,
Card: DemoCardComponent,
});
protected readonly store = signalStateStore({});The store starts empty here because these specs use literal props rather than state expressions.
Mounting the root element
<render-spec> takes the spec, the registry, the store and a loading flag. It renders exactly one element, the one named by spec.root.
<!-- Left: live render output -->
<div primary>
<div class="cap">Live Render Output</div>
@if (simulator.spec(); as renderedSpec) {
<render-spec [spec]="renderedSpec" [registry]="registry" [store]="store" [loading]="simulator.playing()" />
} @else {
<div class="placeholder">Press play to start streaming…</div>
}
</div>While the simulator has produced no spec at all, the template shows a placeholder instead.
Children render because a component renders them
An element's children array reaches the mounted component as a childKeys input, and the full spec arrives with it. A component that wants children loops over the keys and mounts a <render-element> for each, which is how the flat map turns into a tree of any depth.
@Component({
selector: 'demo-heading',
standalone: true,
imports: [RenderElementComponent],
styles: `.h { margin: 0 0 0.5rem; font-size: 1.05rem; font-weight: 700; color: var(--ds-text-primary, #f5f5f5); }`,
template: `
@if (displayContent()) {
<h2 class="h">{{ displayContent() }}</h2>
} @else if (loading()) {
<div class="sr-skeleton" style="height: 18px; width: 12rem; margin-bottom: 0.5rem;"></div>
}
@for (key of childKeys(); track key) {
<render-element [elementKey]="key" [spec]="spec()!" />
}
`,
})
class DemoHeadingComponent {
readonly content = input<unknown>('');
readonly displayContent = computed(() => toDisplayText(this.content()));
readonly childKeys = input<string[]>([]);
readonly spec = input<Spec | null>(null);
readonly bindings = input<Record<string, string>>({});
readonly emit = input<(event: string) => void>(() => {});
readonly loading = input(false);
}The text component in the same file declares childKeys and never loops over it, so a text element is a leaf even when a spec gives it children.
A partial spec still renders
The spec does not arrive whole. StreamingSimulator, a shared helper that sits beside the examples rather than in the block below, is the piece that feeds characters into an incremental JSON parser and materializes whatever is complete so far, so the value handed to <render-spec> grows one element and one prop at a time.
protected readonly specs = SPEC_RENDERING_SPECS;
protected activeIndex = 0;
protected readonly simulator = new StreamingSimulator(this.specs[0].json);
protected readonly jsonTokens = computed(() => highlightJson(this.simulator.rawJson()));A prop can be missing on the first mount and present a frame later. Give every input a default, and use the loading input to draw a placeholder rather than an empty element.
The agent is not in the render path
The capability also ships a backend, and it is worth being clear about what it does not do. This single-node LangGraph agent talks about render specs in prose; it never produces the specs on screen, which this example streams locally.
"""
Render Spec Rendering Graph
A LangGraph StateGraph that returns JSON render specs describing UI layouts.
The Angular frontend uses RenderSpecComponent to render these specs into
live Angular components.
"""
from pathlib import Path
from langgraph.graph import StateGraph, MessagesState, END
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
def build_spec_rendering_graph():
"""
Constructs a graph that generates JSON render specs.
The agent responds with JSON UI specifications that the Angular frontend
renders using RenderSpecComponent from @threadplane/render.
"""
llm = ChatOpenAI(model="gpt-5-mini", streaming=True)
async def generate(state: MessagesState) -> dict:
system_prompt = (PROMPTS_DIR / "spec-rendering.md").read_text()
messages = [SystemMessage(content=system_prompt)] + state["messages"]
response = await llm.ainvoke(messages)
return {"messages": [response]}
graph = StateGraph(MessagesState)
graph.add_node("generate", generate)
graph.set_entry_point("generate")
graph.add_edge("generate", END)
return graph.compile()
graph = build_spec_rendering_graph()Spec format
A spec has one required key for the entry point, one for the elements, and an optional seed for state.
import type { Spec } from '@json-render/core';
const spec: Spec = {
root: 'page',
elements: {
page: { type: 'Container', props: {}, children: ['heading'] },
heading: { type: 'Text', props: { content: 'My App' } },
},
state: {
title: 'My App',
},
};| Property | Type | Description |
|---|---|---|
root | string | The key in elements that rendering starts from |
elements | Record<string, UIElement> | A flat map of every element definition, keyed by a unique string |
state | Record<string, unknown> | Optional. Seeds the internal state store that <render-spec> creates when neither a [store] input nor a provideRender({ store }) is supplied. The internal store is created once and is not re-seeded by a later spec |
Elements are stored in a flat map rather than a nested tree. Parent-child relationships are expressed through the children property, which holds keys pointing at other entries in the same map. That keeps lookups constant-time and makes a spec easy for a model to emit and to patch one element at a time.
Element properties
Each entry in elements is a UIElement:
interface UIElement {
type: string;
props: Record<string, unknown>;
children?: string[];
visible?: VisibilityCondition;
repeat?: { statePath: string; key?: string };
on?: Record<string, ActionBinding | ActionBinding[]>;
// Part of the spec schema; the Angular renderer does not act on it today
// (use `on` bindings or an effect over the store).
watch?: Record<string, ActionBinding | ActionBinding[]>;
}| Property | Type | Description |
|---|---|---|
type | string | The name looked up in the registry |
props | Record<string, unknown> | Inputs for the component. Static values or expressions |
children | string[] | Keys of child elements in the same elements map |
visible | VisibilityCondition | Visibility condition, evaluated against the store |
repeat | { statePath: string; key?: string } | Render this element once per item in a state array. key is schema-level and is not used by the Angular renderer |
on | Record<string, ActionBinding | ActionBinding[]> | Event name to the action or actions it fires |
watch | Record<string, ActionBinding | ActionBinding[]> | State path to the actions that fire when the value there changes. Part of the spec schema; the Angular renderer does not act on it today (use on bindings or an effect over the store) |
Prop expressions
A prop is a literal unless it is an object with one of these reserved keys. Expressions are resolved fresh whenever the store or the surrounding repeat scope changes.
| Expression | Resolves to |
|---|---|
{ $state: '/user/name' } | The value at that JSON Pointer path in the store |
{ $bindState: '/form/email' } | The value at that path, plus the path itself in bindings |
{ $item: 'name' } | A field on the current repeat item. Use '' for the whole item |
{ $bindItem: 'name' } | The same field, plus its absolute path in bindings |
{ $index: true } | The current zero-based repeat index |
{ $cond, $then, $else } | $then when the condition holds, otherwise $else |
{ $computed: 'uppercase', args } | The return value of a registered function, called with resolved args |
{ $template: 'Hi ${/user/name}' } | The string with each ${/path} replaced by the value at that path |
Arrays and plain objects are walked, so an expression nested inside a prop object is resolved too.
Two-way binding
$bindState resolves like $state and additionally populates the component's bindings input with the path, so the component can write back:
const props = {
value: { $bindState: '/form/email' },
};
// The component receives:
// value = the current value at /form/email
// bindings = { value: '/form/email' }$bindItem does the same inside a repeat loop, resolving the item-relative path against the item's base path.
Computed values
$computed names a function in the functions map. Each function is a ComputedFunction from @json-render/core, so it takes a record of already-resolved arguments and returns a value:
import type { ComputedFunction } from '@json-render/core';
const functions: Record<string, ComputedFunction> = {
uppercase: (args) => String(args['text']).toUpperCase(),
};const props = {
label: { $computed: 'uppercase', args: { text: { $state: '/name' } } },
};Pass the map through the [functions] input on <render-spec>, or register it once with provideRender(). An unregistered name resolves to undefined and logs a warning.
Conditional rendering
The visible property decides whether an element mounts. When it evaluates to false the element and everything under it stays out of the DOM. Omitting it means visible.
import type { UIElement } from '@json-render/core';
const never: UIElement = {
type: 'Text',
props: { content: 'Never shown' },
visible: false,
};
const whenFlag: UIElement = {
type: 'Text',
props: { content: 'Shown when the flag is truthy' },
visible: { $state: '/showMessage' },
};
const whenOverFive: UIElement = {
type: 'Text',
props: { content: 'Shown when the count is over five' },
visible: { $state: '/count', gt: 5 },
};A single condition reads $state, $item or $index and applies at most one comparison operator: eq, neq, gt, gte, lt or lte. With no operator it checks truthiness, and not: true inverts the result. An array of conditions is an implicit AND; { $and: [...] } and { $or: [...] } are the explicit forms and may nest.
Repeat loops
repeat renders one copy of the element for each item in a state array:
import type { UIElement } from '@json-render/core';
const item: UIElement = {
type: 'ListItem',
props: {
label: { $item: 'name' },
position: { $index: true },
},
repeat: { statePath: '/todos' },
};For each item the renderer builds a repeat scope holding the item, its index and its base path (/todos/0, /todos/1, and so on), provides that scope through a child injector, and resolves the element's props inside it, so $item, $bindItem and $index mean something different in every copy.
An element with repeat renders every item. To hide individual items, branch inside the component that renders them.
The repeat loops guide covers the scope and the write-back paths in full.
What's Next
How type names resolve and what a registered component receives
Reading and writing the store that expressions resolve against
Event handler bindings and action dispatch
Full API reference for the entry-point component
How an agent streams a spec that chat renders inline