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 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.
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 treehide 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": []
}
]
}
]
}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.type | where this node is, and what it is |
loom.editable | spread it; present in edit mode |
loom.slots | the content of this node's named regions |
loom.theme | the resolved theme, on the root node only |
loom.data | what the host answered for this node's bindings |
loom.text | the 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.