Scaffolding a project
You have the package installed. What you do not have yet is anywhere to put the components AI is allowed to use on your pages — and that list, more than anything else you will write, is the thing that decides what Loom can and cannot do to your site.
One command makes the place and puts three files in it.
loom init
Everything on this page is that command and its neighbours actually running. The paths, the closing lines, the file contents and the refusals below were all produced by the CLI as this page was built, so none of it can quietly stop being true.
One command, three files
Three files, and they are three different kinds of thing:
loom/primitives/app.page.ts is a component of your own — a real one, with
nothing in it yet. registry.ts is the list of what AI may use, and the CLI
writes it rather than you. registry.test.ts is a test, and the rest of this
page is largely about why the scaffold insists on giving you one.
What a primitive looks like before you have written anything
import { createElement } from "react"
import { z } from "zod"
import type { LoomPrimitiveProps } from "@jam-overture/loom/react"
import { definePrimitive } from "@jam-overture/loom/sdk"
/**
* Every prop a tree may set on this primitive. The render seam refuses a node
* whose props do not satisfy this schema, so it is the one place that decides
* what AI is allowed to configure here.
*/
const props = z.object({}).strict()
type Props = z.infer<typeof props>
export const appPage = definePrimitive({
type: "app.page",
/** One line. This is what a model reads when choosing between primitives. */
description: "Describe what app.page is for",
props,
/** Name a slot here for every region this primitive projects children into. */
slots: [],
component: ({ loom, children }: LoomPrimitiveProps<Props>) =>
/**
* Spreading `loom.editable` is the edit-mode contract: without it this
* primitive renders correctly and is invisible to the portal. Read declared
* props off the `props` bag — they are never spread onto the element.
*/
createElement("div", { ...loom.editable }, children),
})
That file is mostly a shape to fill in, and three parts of the shape are the whole contract between your code and the runtime.
props is the schema, and it is a fence. A model proposing a change to your
page may set the props declared here and nothing else. An empty z.object({})
means a model may set nothing at all — which is a perfectly good place to start,
because you can widen it later and narrowing it afterwards is the change that
breaks pages.
description is read by a model, not just by you. It is the one line the
runtime shows when something has to choose between your primitives. A
description that says "Describe what commerce.product-card is for" will be
chosen by a model exactly as badly as that reads.
loom.editable is what makes the component reachable. Spreading it onto the
element you return is how the portal finds this node on the rendered page.
Leave it off and the primitive still renders, still validates, still passes
every check you would think to run — and cannot be edited by anyone, with no
error anywhere to say so.
The registry rewrites itself
The second command declares another primitive.
Notice what it says it did to the file it had already written: it regenerated
it. The registry is not appended to. It is rebuilt from whatever .ts files are
sitting in that directory, every time.
/**
* Generated by `loom add primitive`. Edit the definitions, not this file — it is
* rewritten from the contents of this directory every time a primitive is added.
*/
import { createPrimitiveRegistry, describeRegistryError } from "@jam-overture/loom/sdk"
import { appPage } from "./app.page.js"
import { commerceProductCard } from "./commerce.product-card.js"
const built = createPrimitiveRegistry([
appPage,
commerceProductCard,
])
if (!built.ok) {
throw new Error(`loom: the primitive registry was refused — ${describeRegistryError(built.error)}`)
}
export const registry = built.value
Two consequences worth holding on to. Do not edit that file — the next
loom add primitive will overwrite whatever you put there, which is why it says
so at the top of itself. And a primitive is registered by existing: delete
the module and run the command again, and the registration goes with it. There
is no second list to keep in step.
The filename is the type verbatim, dots included — commerce.product-card.ts,
not commerce-product-card.ts. That is deliberate rather than untidy. The
registry is rebuilt from the filenames, and folding a dot into a dash throws
away the information needed to fold it back: nothing could tell whether
commerce-product-card had been commerce.product-card or
commerce-product.card.
The third file is the point
The scaffold could have written two files and left you to test your own code. It writes three, because there is one failure in this system that reports nothing at all.
A primitive that ignores loom.editable renders perfectly. Its schema is valid,
its props typecheck, the page looks right, and no diagnostic anywhere mentions
it. The only symptom is that when somebody tries to edit that part of the page,
nothing happens — which they will discover long after you have moved on.
The runtime can find it. auditRegistry walks a registry, renders each
primitive with props built from its own schema, and reports what it saw. But it
only reports — it never refuses a registry, and never stops a build. So it is
worth nothing unless something runs it, and a host is far more likely to keep a
test that was in the first commit than to add one later. That is the whole
argument for the third file.
import { describe, expect, it } from "vitest"
import { auditRegistry, describeRegistryAudit } from "@jam-overture/loom/sdk"
import { registry } from "./registry.js"
/**
* Generated by `loom init`. Keep it: a primitive that ignores `loom.editable`
* renders perfectly and is invisible to the portal, with no error anywhere. This
* is the only thing that catches it, and it catches it at build time rather than
* when someone is trying to edit a page.
*/
describe("the primitive registry", () => {
it("registers primitives that are all visible to the portal", () => {
const audit = auditRegistry(registry)
expect(audit.notDecorated, describeRegistryAudit(audit)).toEqual([])
})
/**
* `not-probeable` is neither a pass nor a failure — a hook-using or class
* component cannot be called outside a renderer. Assert the list you expect so
* a newly unprobeable primitive is a decision rather than a surprise.
*/
it("has no primitives the probe could not judge", () => {
const audit = auditRegistry(registry)
expect(audit.notProbeable, describeRegistryAudit(audit)).toEqual([])
})
/**
* The one that stops a page rather than a portal: a component that throws on a
* value its own schema accepts is one a tree the validator *accepted* can take
* down. If you add a hook-using primitive, the probe cannot tell it from a
* broken one and it will appear here — narrow this to the entries with
* `everyConfiguration: false`, which are the ones the audit is certain of.
*/
it("has no primitives that throw on props their own schema accepts", () => {
const audit = auditRegistry(registry)
expect(audit.throwsOnDeclaredProps, describeRegistryAudit(audit)).toEqual([])
})
})
Three checks, and they are not the same severity:
Not decorated is the invisible failure above: registered, renders, cannot be edited. Not probeable is neither a pass nor a failure — a component that uses hooks cannot be called outside a renderer, so the audit declines to judge it rather than guessing. Throws on declared props is the one that takes a page down: a component that crashes on a value its own schema accepts can be crashed by a tree the validator was right to accept.
It refuses rather than half-writing
Both commands decide everything they would write before they write anything, by reading what is already in the directory. So a command that would clash writes nothing at all, rather than leaving you half a scaffold to clean up.
| You ran | It said | What that protects |
|---|---|---|
| loom init | loom/primitives/registry.ts already exists; nothing was written | Scaffolding again over a populated directory would discard every registration but the starter. |
| loom add primitive loom.card | "loom.card" is in the framework's namespace — @jam-overture/loom registers loom.* and a registry refuses two definitions with one type. Try "app.card" | @jam-overture/loom registers the loom.* types itself, and a registry refuses two definitions of one type — so a primitive scaffolded there would be unreachable in the app that wrote it. |
| loom add primitive app.page | "app.page" is already declared — edit its definition rather than regenerating it | Re-running the command over a primitive you have written would throw your definition away. |
| loom add primitive registry | "registry" is reserved: its module would overwrite the generated registry | A primitive really could be typed registry, and its module would land exactly where the generated registry lives. |
| loom add primitive ProductCard | "ProductCard" is not a valid primitive type — expected dot-namespaced kebab-case, like commerce.product-card | A type is checked as a string, before the disk is touched — a typo comes back as a typo. |
| loom build | "build" is not a loom command — run loom --help | A command the CLI does not have is a typo, not a request to guess. |
| loom add primitive | <type> is required — run loom --help | Nothing is invented for you — an unnamed primitive is not scaffolded under a placeholder name. |
| loom init app | "app" was not expected — run loom --help | A word in the wrong place is reported rather than ignored, so a mistyped option is never silently dropped. |
| loom init | could not write loom/primitives/app.page.ts: EACCES: permission denied | The disk itself said no, and the CLI names the path rather than the operation. |
After the refused loom init above, the directory still holds the 1 file it held before and nothing else — counted from the disk the run was given, not asserted.
Four of those are ordinary mistakes at the keyboard. The other four are the
command protecting work you have already done, and the one worth reading twice
is registry: it is a perfectly legal primitive type, and a module named for it
would land exactly where the generated registry lives and erase it. There is no
--force, on purpose — the recovery from a wrong --force here is retyping
every registration you had.
Putting it somewhere else
--dir is the only option, and it moves the whole directory.
The whole surface
Two commands and one option is all of it.
loom — scaffolding for the Loom primitive registry Usage: loom init [--dir <directory>] loom add primitive <type> [--dir <directory>] loom --help Options: --dir <directory> Where the Loom source lives (default: loom) init writes a starter primitive, a generated registry, and a conformance test into <directory>/primitives. add primitive declares one more and regenerates the registry from the directory's contents. A type is dot-namespaced kebab-case: app.card, commerce.product-card. loom.* is the framework's own namespace and is refused — those names belong to the primitives @jam-overture/loom already registers.
The CLI is scaffolding and stops there. It does not build trees, call a model, or know anything about a running page — everything after this point is library code you call yourself, starting with the next page.