Prototyper UI

Spec Format

The flat JSON structure that describes a Stream UI interface

A spec is the JSON document that describes an entire UI. Stream UI uses a deliberately flat structure optimized for streaming and efficient patching.

The Spec Type

Every spec has three fields:

interface Spec {
  /** Key of the root element in the elements map. */
  root: string
  /** Flat map of element keys to element definitions. */
  elements: Record<string, UIElement>
  /** Optional initial state model. */
  state?: Record<string, unknown>
}
FieldRequiredDescription
rootYesThe key in elements that serves as the tree root.
elementsYesA flat dictionary of all UI elements, keyed by unique string IDs.
stateNoInitial state values, readable and writable via JSON Pointer paths.

UIElement Fields

Each element in the elements map is a UIElement:

interface UIElement {
  /** Component type from the registry (e.g. "Button", "Card", "Input"). */
  type: string
  /** Component props — may contain dynamic expressions. */
  props: Record<string, unknown>
  /** Ordered array of child element keys. */
  children?: string[]
  /** Visibility condition — controls conditional rendering. */
  visible?: VisibilityCondition
  /** Event bindings — maps event names to action bindings. */
  on?: Record<string, ActionBinding>
  /** Repeat over items in a state array. */
  repeat?: RepeatBinding
  /** State paths to watch for triggering side effects. */
  watch?: string[]
}
FieldRequiredDescription
typeYesComponent type name matching a key in the component registry.
propsYesProps object passed to the component. Values can be literals or expressions.
childrenNoArray of element keys rendered as children, in order.
visibleNoA visibility condition controlling whether this element renders. Defaults to true.
onNoEvent-to-action map. Keys are event names (press, change, blur), values are ActionBinding objects.
repeatNoBinds the element to iterate over items in a state array. Children are rendered once per item.
watchNoArray of state paths. When a watched value changes, the watch:<path> event fires.

Why Flat (Not Nested)?

Stream UI uses a flat element map instead of a nested tree for three key reasons:

Streaming-Optimized

A flat structure lets the model output elements in any order. Each JSON Patch targets a specific element by key:

{"op":"add","path":"/elements/email-input","value":{"type":"Input","props":{"label":"Email"}}}

With nested trees, adding a deeply nested element requires the model to output the full path through all ancestors.

Efficient Patching

Updating a single element only requires a patch to /elements/<key>. Siblings and parents are untouched. React re-renders only the affected subtree.

Independent Updates

Elements can be added, removed, or modified independently. An element's children are references (string keys), not inline definitions, so restructuring the tree only requires changing children arrays.

Element Keys

Keys are semantic string identifiers. Use descriptive names that reflect the element's purpose:

{
  "root": "login-card",
  "elements": {
    "login-card": { "type": "Card", "props": {}, "children": ["heading", "form-fields", "submit-btn"] },
    "heading": { "type": "Heading", "props": { "text": "Sign In", "level": 2 } },
    "form-fields": { "type": "Card", "props": {}, "children": ["email-input", "password-input"] },
    "email-input": { "type": "Input", "props": { "label": "Email", "type": "email" } },
    "password-input": { "type": "Input", "props": { "label": "Password", "type": "password" } },
    "submit-btn": { "type": "Button", "props": { "label": "Sign In" } }
  }
}
Open in Playground

Conventions:

  • Use lowercase kebab-case: email-input, submit-btn, login-card
  • Name elements by role, not type: heading not heading-1, submit-btn not button-3
  • Keep keys short but unambiguous within the spec

Repeat Binding

The repeat field binds an element to iterate over a state array:

interface RepeatBinding {
  /** JSON Pointer path to a state array in the state model. */
  source: string
  /** Field on each item to use as the React key (optional). */
  itemKey?: string
}

When repeat is set, the element's children are rendered once for each item in the source array. Inside the repeat scope, child elements can use $item and $index expressions.

{
  "root": "list",
  "elements": {
    "list": {
      "type": "Card",
      "props": {},
      "children": ["item-text"],
      "repeat": { "source": "/todos", "itemKey": "id" }
    },
    "item-text": {
      "type": "Text",
      "props": { "content": { "$item": "title" } }
    }
  },
  "state": {
    "todos": [
      { "id": "1", "title": "Buy groceries" },
      { "id": "2", "title": "Walk the dog" }
    ]
  }
}

The nestedToFlat() Utility

If you prefer writing nested trees for convenience, the nestedToFlat() function converts them to the flat format:

import { nestedToFlat } from "@prototyperai/stream-ui/core"

const spec = nestedToFlat({
  state: { name: "World" },
  root: {
    type: "Card",
    props: {},
    children: [
      { type: "Heading", props: { text: "Hello", level: 2 } },
      { type: "Text", props: { content: "Welcome" } },
      {
        type: "Button",
        props: { label: "Click" },
        on: { press: { action: "setState", params: { path: "/clicked", value: true } } },
      },
    ],
  },
})

The converter auto-generates element keys (el-0, el-1, ...) and preserves all fields including visible, on, repeat, and watch.

Full Annotated Example

Here is a complete spec for a contact form with validation and conditional state:

{
  "root": "form-card",
  "elements": {
    "form-card": {
      "type": "Card",
      "props": {},
      "children": ["heading", "name-input", "email-input", "message-input", "submit-btn", "success-msg"]
    },
    "heading": {
      "type": "Heading",
      "props": { "text": "Contact Us", "level": 2 }
    },
    "name-input": {
      "type": "Input",
      "props": {
        "label": "Name",
        "placeholder": "Your name",
        "value": { "$bindState": "/form/name" }
      }
    },
    "email-input": {
      "type": "Input",
      "props": {
        "label": "Email",
        "type": "email",
        "placeholder": "you@example.com",
        "value": { "$bindState": "/form/email" }
      }
    },
    "message-input": {
      "type": "Textarea",
      "props": {
        "label": "Message",
        "placeholder": "How can we help?",
        "value": { "$bindState": "/form/message" }
      }
    },
    "submit-btn": {
      "type": "Button",
      "props": { "label": "Send Message" },
      "on": {
        "press": {
          "action": "setState",
          "params": { "path": "/submitted", "value": true }
        }
      },
      "visible": { "$state": "/submitted", "not": true }
    },
    "success-msg": {
      "type": "Alert",
      "props": {
        "title": "Message sent!",
        "description": { "$template": "Thanks ${/form/name}, we'll get back to you at ${/form/email}." }
      },
      "visible": { "$state": "/submitted" }
    }
  },
  "state": {
    "form": { "name": "", "email": "", "message": "" },
    "submitted": false
  }
}

This example demonstrates:

  • Two-way binding with $bindState on form inputs
  • Visibility conditions to toggle between the form and success message
  • Template expressions for dynamic text interpolation
  • Action binding on the submit button
Open in Playground

Next Steps

  • Expressions — All dynamic value types
  • Visibility — Conditional rendering in depth
  • Actions — Event handling and state mutations
  • Streaming — How patches build specs incrementally

On this page