skip to the page

Primitives and the registry

A primitive is three things: a name a tree may say, a schema its props must satisfy, and a component that renders it. The registry is the set of them a given surface allows.

That is the whole vocabulary bargain in one sentence. AI can arrange your primitives; it cannot invent one, and it cannot reach past a schema.

Defining one

import { createElement } from "react"
import { z } from "zod"

import { definePrimitive } from "@jam-overture/loom/sdk"
import type { LoomPrimitiveProps } from "@jam-overture/loom/react"

const props = z
  .object({
    value: z.string().min(1).max(24),
    label: z.string().min(1).max(80),
    caption: z.string().min(1).max(160).optional(),
  })
  .strict()

type Props = z.infer<typeof props>

export const stat = definePrimitive({
  type: "loom.stat",
  description: "A single figure with a short label — one cell of a loom.stat-grid.",
  props,
  slots: [],
  component: ({ loom, props: given }: LoomPrimitiveProps<Props>) =>
    createElement("div", { ...loom.editable }, given.value, given.label),
})

Four of those five fields are doing more work than they look like.

type is what a node names, and what a model reads in the catalogue. It is a lowercase, dotted identifier — namespace your own.

description is not a comment. It is shown to the model as part of the catalogue of what it may propose, and it is the single most effective place to say when not to use this primitive.

props is a Zod schema, and .strict() matters: it makes an unrecognized prop a rejection rather than a value that rides along. Bound every string. A label with no maximum is an invitation for a model to write an essay into your layout.

component receives a bag — loom, props, children — and reads what it declared. props are validated against exactly the schema above before it is called, so the type is a claim the render seam has already checked.

Prop or child?

The question that comes up at every primitive, and the rule is short:

  • Repeated content is a node. Three features in a grid are three children, so a proposal can add a fourth with an insert.
  • A fixed field is a prop. A stat has exactly one value and one label, so changing either is exactly a configure.

Getting it backwards has a cost either way. Fixed fields as children make a stat whose label was deleted a still-valid tree. Repeated content as props makes "add a feature" impossible to say without a new primitive.

A container over a repeated childlive · rendered through the runtime

A tree, not a template

The page is data, so a change to it is addressable rather than a diff of generated code.

A delta, not a rewrite

Four operations against an existing tree — insert, remove, move, configure.

A gate, not a hope

A pure function decides what is allowed, before anything is applied.

Propose a change:

Every one of these goes through the same pipeline a model’s answer would: interpreted, analysed, weighed, judged, applied, appended. The log is real and it is in this tab — reload the page and the example is back as it was.

show the tree
{
  "kind": "element",
  "id": "n_featuregrid5",
  "type": "loom.page",
  "props": {
    "fills": true,
    "width": "wide",
    "loom:theme": {
      "palette": "minimal",
      "fontPack": "minimal-sans",
      "stylePreset": "precise"
    }
  },
  "children": [
    {
      "kind": "element",
      "id": "n_featuregrid4",
      "type": "loom.feature-grid",
      "props": {
        "columns": "three"
      },
      "children": [
        {
          "kind": "element",
          "id": "n_featuregrid1",
          "type": "loom.feature",
          "props": {
            "icon": "◇",
            "title": "A tree, not a template",
            "body": "The page is data, so a change to it is addressable rather than a diff of generated code.",
            "surface": "card"
          },
          "children": []
        },
        {
          "kind": "element",
          "id": "n_featuregrid2",
          "type": "loom.feature",
          "props": {
            "icon": "◈",
            "title": "A delta, not a rewrite",
            "body": "Four operations against an existing tree — insert, remove, move, configure.",
            "surface": "card"
          },
          "children": []
        },
        {
          "kind": "element",
          "id": "n_featuregrid3",
          "type": "loom.feature",
          "props": {
            "icon": "◆",
            "title": "A gate, not a hope",
            "body": "A pure function decides what is allowed, before anything is applied.",
            "surface": "card"
          },
          "children": []
        }
      ]
    }
  ]
}
A feature grid arranges feature nodes. The repeated thing is a node, so a proposal can add one — a fixed field would have needed a new primitive.

What a component gets

loom is the runtime's own bag, kept separate from your props so nothing can be smuggled in through it:

loom.nodeId, loom.typewhere this node is, and what it is
loom.editablespread it; present in edit mode
loom.slotsthe content of this node's named regions
loom.themethe resolved theme, on the root node only
loom.datawhat the host answered for this node's bindings
loom.textthe strings this primitive declared, translated

loom.text is worth knowing about early. A few strings a primitive shows are not in the tree — the accessible name on a marker glyph, for instance — and those are declared beside the component rather than left inline, so a deployment can translate them:

text: { excluded: "Not included" }

The keys are typed from that declaration, so reading one you did not declare does not compile, and loom.text.excluded is always a string — never a missing key at render time.

Registering

import { createPrimitiveRegistry, describeRegistryError } from "@jam-overture/loom/sdk"

const built = createPrimitiveRegistry([stat, statGrid, heading])

if (!built.ok) throw new Error(describeRegistryError(built.error))

The registry refuses rather than repairs: a duplicate type, a definition whose declarations disagree with its schema. It is built once at composition and passed to the render.

Start from the starter library

@jam-overture/loom-primitives is a library covering the structural range — pages and sections, headings and prose, containers over repeated children, a hero, a pricing table. Every example on this site is built from it.

import { createStarterPrimitiveRegistry } from "@jam-overture/loom-primitives"

It is a separate package from the framework, installed beside it, and that is the same argument this page has been making from the top: the framework does not know which primitives exist, so it cannot ship them. A vocabulary is somebody's choice, and the starter library is one choice rather than the language itself.

Register a slice rather than all of it when you only want part — a smaller registry is a smaller prompt on every request, and the library is a set to choose from:

import { STARTER_PRIMITIVES } from "@jam-overture/loom-primitives"
import { selectPrimitives } from "@jam-overture/loom/sdk"

const chosen = selectPrimitives(STARTER_PRIMITIVES, ["loom.hero", "loom.prose"])

It is a good place to begin and a better place to read: each primitive is a worked answer to the prop-or-child question above.