@jam-overture/loom
v0.1.0
Published
Loom — an AI-powered adaptive UI runtime
Maintainers
Readme
Loom
An AI-powered adaptive UI runtime. A UI is a validated tree; AI proposes discrete changes to that tree; a pure Gate function decides what is allowed; the runtime applies what survives and renders the result.
Pre-production alpha. Nothing here is stable yet.
npm install @jam-overture/loomWhat is in the package, and what is not
@jam-overture/loom is the framework: the tree and its deltas, the Gate, the
interpretation seam, the render seam, the store, the write path, signals,
telemetry, the SDK, the testing contracts and the loom CLI. Fifteen entry
points, listed under Entry points.
Four things that live in this repository are deliberately not in it, and
each is somebody's product rather than the framework's plumbing
(decisions/0194-the-framework-is-the-package-and-everything-that-uses-it-ships-separately.md):
| not published here | what it is |
| --- | --- |
| the starter primitive library | ten primitives a deployment may adopt — a library to choose from, with its own release rhythm |
| the marketing site, the documentation, the lessons | apps/loom, one Next.js application, deployed rather than packaged |
| the demonstration | a surface, not an import |
| the portal | a tool over a deployment's own tree |
So @jam-overture/loom/primitives resolves inside this workspace and is not a
subpath of the published package. Everything the README says about the
starter primitives below is about this repository; a separate package for them
is the next step and does not exist yet.
Entry points
Fifteen, each a subpath of the one package. Everything after . is there so a
host can take a part of Loom without the rest; the optional peers are optional
because the core is usable without any of them.
| import | what it is |
| --- | --- |
| @jam-overture/loom | the tree, the deltas, the Gate, the runtime pipeline, the vocabularies |
| @jam-overture/loom/anthropic | the interpretation seam against Anthropic's API — needs @anthropic-ai/sdk |
| @jam-overture/loom/react | tree to React, per request — needs react |
| @jam-overture/loom/sdk | the primitive registration contract, registries and audits — needs react |
| @jam-overture/loom/cli | the loom scaffolding command, as a library |
| @jam-overture/loom/store | tree storage, in memory |
| @jam-overture/loom/write | the write path: intent in, disposition out |
| @jam-overture/loom/postgres | the same store over Postgres — needs drizzle-orm |
| @jam-overture/loom/signals | reader signals, parsed and reduced |
| @jam-overture/loom/signals/broadcast | the browser half: a published page reporting what readers did |
| @jam-overture/loom/signals/postgres | signal storage — needs drizzle-orm |
| @jam-overture/loom/telemetry | proposal, provenance, disposition and outcome |
| @jam-overture/loom/telemetry/postgres | telemetry storage — needs drizzle-orm |
| @jam-overture/loom/testing | fixtures and sample trees |
| @jam-overture/loom/testing/contracts | the conformance suites a primitive must pass — needs vitest, and only loads inside a test run |
@jam-overture/loom itself depends only on zod. react, @anthropic-ai/sdk,
drizzle-orm and vitest are optional peer dependencies: install the ones the
subpaths you use name, and nothing else.
Why a tree and a delta, not generated code
AI that emits UI code cannot be reviewed, gated, or reverted at a useful
granularity. Loom narrows what AI may produce to a TreeDelta — an ordered list
of four discrete operations against an existing tree. That makes every proposed
change addressable ("insert a checkout button into the sidebar"), reviewable by a
pure function, attributable in telemetry, and reversible.
Build order
- Tree schema — the AST, the NodeId scheme,
TreeDelta - Composition Runtime —
EditIntent → ProposedChange → Gate → Disposition → Apply - Adaptive Renderer — edge/RSC resolver, tree → React, per request
- Framework SDK — primitive registration contract, CLI scaffolding
- Portal — a thin UI over the persisted tree
- Telemetry — proposal, provenance, disposition, and outcome, captured from day one
- Marketplace — last, and not started
Sections 1–6 are built and run end to end. What is still open in each is tracked
in the latest report in reports/ rather than here, because a marker
in a README is a thing that goes stale quietly.
Reader signals
The part of adaptation that happens on a live page. A render with
addressed: true puts node ids on published markup, and
@jam-overture/loom/signals/broadcast reports four closed kinds of signal from it —
viewed, dwelled, activated, disclosed — naming nodes and never content,
filed under the tree and revision the page was rendered from
(0136).
Off unless a host starts it, and configured by the host rather than the tree.
Broadcasting is built. Capturing, storing and showing signals are approved as
of 13 September and planned in docs/signals.md, which
supersedes the deferral in reports/2026-09-12-reader-signals.md: five steps in
order, each with the lane that owns it, and the rules the seam keeps — a signal
stays anonymous and node-shaped, and a funnel is correlated inside one page view
(0146).
Read it before starting anything that consumes signals.
4b — The primitive library
§4 built the contract a primitive registers under. It never built anything to register, and an empty registry is the reason nothing downstream can be shown: the portal has no interesting tree to review, a marketplace would have nothing to list, and no demo exists. The vocabulary is the gap between an architecture and a product.
The source is the Hermes predecessor — 70 block definitions with 72 React renderers, 54 shapes, 18 layouts, and a theme system of 29 palettes, 22 font packs and 10 style presets. It is a port, not a copy; four things differ:
| | Hermes | Loom |
| --- | --- | --- |
| Prop model | FieldDef[] over a custom union | Zod schema per primitive |
| Composition | leaves in a flat warp.blocks list | nesting, via slots and children |
| Data | binding fields resolved from profile + integrations | plain JSON props, no resolution layer |
| Renderer contract | {instance, resolvedFields, isOwner, onEdit} | {loom, props, children}, spreading loom.editable |
Order, and the reason for it:
Theme — done (0049, 0050). Three registered ids on the root node, resolved per render and mounted by the root primitive as CSS custom properties, so a ported renderer reading
var(--loom-accent)is styled and a re-theme is an ordinaryconfigure.Ten primitives, not seventy — done (0051, 0052, 0053). Chosen to cover the contract rather than the catalogue: four that compose, two of them through named slots; five leaves; one with a rich prop schema and a cross-field rule; one driven by an enum that changes what is rendered; and one container/child pair that shows how every remaining Hermes list block decomposes. See the starter library.
The demo vocabulary, and then the demo — done. The vocabulary came first (eight composed primitives, taking the library to eighteen) because a page built from
section+prose+stat-gridis a layout demo, and what this step has to produce is something that looks like a product. The demo list and the marketing list are one list, so §4d builds the real site from these rather than beside them.The demo itself is
/demo, public, in the portal deployment (0056): a real page on the left, and on the right the record of every change to it — the proposal with its rationale and provenance, the stakes factors and the reversibility verdict, which rule fired under which policy, the revision it produced and what it replaced, and an undo that is a proposal like any other. Five scripted changes cover all four delta operations and need no key (0057); free text goes to the model when one is configured. It is the first end-to-end run of §1–§6 against something not written to pass its own tests.Three things approved on #68 rode along with the vocabulary and are done:
auditRegistryprobes for slot placement and reports which primitives are leaves; the theme registry joins the catalogue the model is shown; and decomposed pairs have a naming rule (0054).Data — the seam the port hits at the first primitive that needs any (§4e, done), text — the strings a primitive owns rather than reads from the tree (§4f, done), and submissions — where a form's contents go (§4g, done). All three are the same shape of gap: something a page needs that props cannot carry.
The remaining primitives, which by then are mechanical — and they come after §4c and §4d, because a site that shows the runtime is worth more than the fifty-second block. Only then §7, since a marketplace of primitives needs primitives.
Refinement inside §1–§6 is reactive from here: driven by what the port breaks, not run as its own stream.
4e — Data: what a primitive cannot be told in props
Five of the seventy Hermes blocks resolve a binding rather than reading a
prop: about binds three fields to one profile, and services, products,
feed and marquee bind a list to a connected integration. Ported without an
answer they become an author typing their own services in by hand, which is not
the block anyone used.
A binding is a question the tree asks, and never an answer
(0058).
loom:data is the second key in the reserved namespace 0050 opened, and it
names a registered source and the params to ask it with:
"loom:data": { "services": { "source": "catalogue.services", "params": { "limit": 6 } } }Resolution happens in three steps, and the middle one is the only IO in serving
a page: planTreeData reads every binding out of the tree as a pure pass and
deduplicates it, resolveDataPlan asks every question at once, and
renderLoomTree takes the finished answers and stays as synchronous as it was.
renderRequest is where the three meet.
A source is registered like a primitive is. defineSource declares what it
accepts and what it answers, both as Zod; the registry is the allowlist a
binding can reach, params are checked before the adapter is called and the
answer after. A primitive reads loom.data, beside props rather than merged
into it, and every answer is either ready or unavailable with a reason —
"you have no services yet" and "we could not reach your services" are different
sentences and a shape that cannot tell them apart eventually shows the wrong one.
What is not built yet is the authoring half: a primitive declaring which binding
names it reads, so the catalogue can tell a model that loom.services wants a
services binding. Until then a bound tree is hand-authored, and no starter
primitive binds anything.
4f — Text: the strings a primitive owns
Almost every user-facing string on a page comes from the tree. A few cannot: a
marker glyph carries an accessible name the visible label does not say, and
making that a prop would put an accessible name in the space a model writes.
Left inline in the component it is untranslatable, and a German deployment
renders an English "Not included" in the middle of a German pricing table — the
gap the primitives routine hit at loom.perk and filed.
A primitive declares the strings it owns; a deployment may replace them
(0060).
definePrimitive takes a text map in the author's own language, the keys are
typed so a component can only read what it declared, and what reaches
loom.text is the translation where there is one and the declared string where
there is not — never a missing key.
text: { excluded: "Not included", coming: "Coming soon" }A registry is a TextResolver over its own declarations, so an untranslated
deployment is the base case rather than a fallback path. textResolverFor lays a
dictionary over it, merging once per dictionary rather than once per node;
textCatalogue is the extraction a translation file is written from, and
textCoverage says what a dictionary answers, what it does not, and which of its
keys name nothing. Which language a visitor gets stays the host's decision — the
framework never reads a request header.
4g — Submissions: where a form's contents go
Three of the seventy Hermes blocks are blocked on a seam rather than on a
primitive, and two of them are the same one: contactform and newsletter are a
field list and a submit. The field list is an ordinary
0052
decomposition. The submit is a decision about where a deployment's data goes,
and a URL in a prop is AI-authored by definition — a scheme allowlist does not
help, because https://collect.example.com/harvest passes it.
A submission names a destination, and never carries one (0065). A node names a registered endpoint and nothing else:
"loom:submit": { "to": "contact.enquiry" }defineEndpoint is the host's half — it answers with an action, a method,
and the hidden fields a form must carry, a CSRF token being why that list
exists. Resolution happens before the walk, beside the data seam and at the same
time as it, so the renderer stays synchronous. A form has a target, or an
unavailable with a reason, or nothing at all when the tree never said — three
states, because a submit button that silently goes nowhere is the failure the
shape exists to prevent. There are deliberately no params: a deployment with two
lists registers two endpoints, and the id is the whole AI-authored surface.
4c — The documentation site ← current
Modelled on nextjs.org/docs. Two decisions are settled and are not worth
relitigating:
- MDX for prose and code blocks. Documentation is prose, and prose is not improved by being a node tree. The tree model earns its keep where a page is configured rather than written.
- Every rendered example on the page is a real
LoomTree, mounted through the runtime with a working propose-a-change box beside it. An example is a registry entry, so an example that cannot render is a failing test rather than a stale snippet — which is only affordable because a demonstrated change is a deterministic interpreter (0057) and needs no key to run.
4d — The marketing site
Built entirely in Loom: composed sections, themed, adapting. It reuses the §4b step 3 primitives — the site is the proof, not a brochure about it — and it embeds the demo rather than describing it, which is why the demo is public (0056).
Positioning, audience and licensing are the maintainer's and are not engineering questions. They are asked, not invented.
Layout
src/
├── ids.ts # Brand-typed NodeId / TreeId / DeltaId + the IdFactory seam
├── json.ts # The JSON value space every boundary is restricted to
├── primitive-type.ts # Primitive type and slot name identifiers
├── reserved-props.ts # The `loom:` namespace: the runtime's own props
├── result.ts # Result<T, E> — nothing throws across a seam
├── testing/ # Deterministic fixtures and test doubles
├── tree/
│ ├── node.ts # The discriminated-union AST: element | text | slot
│ ├── tree.ts # The LoomTree document, revisions, boundary parsing
│ ├── navigation.ts # Read-only traversal
│ ├── configuration.ts # The settable surface of each node kind
│ ├── mutation.ts # Immutable structural edits
│ ├── delta.ts # TreeDelta and its four operations
│ ├── apply.ts # Pure, atomic delta application
│ ├── inverse.ts # The delta that undoes a delta
│ └── builders.ts # Construction helpers for trusted callers
├── runtime/
│ ├── intent.ts # EditIntent — what someone wants, before interpretation
│ ├── proposal.ts # ProposedChange + Provenance — what came back, and from where
│ ├── interpreter.ts # The AI seam; the only non-deterministic step
│ ├── analysis.ts # Pure facts about what a delta does
│ ├── stakes.ts # How much damage, as named factors
│ ├── reversibility.ts # Whether it can be taken back, via the inverse
│ ├── policy.ts # GatePolicy — the knobs, host and structural
│ ├── policy-source.ts # Which policy judges a given change
│ ├── gate.ts # The pure decision function
│ ├── events.ts # RuntimeEvent, EventSink, Clock
│ └── pipeline.ts # composeChange / confirmChange
├── interpretation/ # The model-backed ChangeInterpreter
│ ├── client.ts # ModelClient — the whole network boundary
│ ├── draft.ts # What a model may say: the AST, minus identity
│ ├── schema.ts # The JSON Schema its reply is constrained to
│ ├── render.ts # The tree as an outline the model can address
│ ├── prompt.ts # Prompt assembly and the provenance hash
│ ├── materialize.ts # Draft → TreeDelta, minting every new id
│ ├── interpreter.ts # The ChangeInterpreter itself
│ └── anthropic.ts # Vendor adapter — a separate entry point
├── render/ # The adaptive renderer — a separate entry point
│ ├── primitive.ts # What a primitive receives; the resolver seam
│ ├── props.ts # The prop-validation seam
│ ├── editable.ts # Edit-mode decoration, as attributes
│ ├── diagnostics.ts # What rendering could not honour
│ ├── theme.ts # The reserved prop namespace, and the theme read from it
│ ├── text.ts # The strings a primitive owns, and the seam that translates them
│ ├── render.ts # The tree, projected into React
│ └── request.ts # Per-request resolution: load, validate, render
├── data/ # The data seam — the only IO in serving a page
│ ├── source.ts # Source ids and binding names
│ ├── binding.ts # What a node asks, as it appears in the tree
│ ├── adapter.ts # defineSource, the adapter contract, the registry
│ ├── plan.ts # The tree's questions, deduplicated — a pure pass
│ ├── resolve.ts # Asking them, all at once, catching everything
│ ├── resolution.ts # The answers, indexed the way the walk needs them
│ └── catalogue.ts # What a deployment can ask about, as data
├── submit/ # The submission seam — where a form's contents go
│ ├── endpoint.ts # defineEndpoint, the target contract, the registry
│ ├── declaration.ts # What a node names, as it appears in the tree
│ ├── plan.ts # The tree's endpoints, deduplicated — a pure pass
│ ├── resolve.ts # Asking them, all at once, catching everything
│ ├── resolution.ts # The targets, indexed the way the walk needs them
│ └── catalogue.ts # Where a deployment will accept a submission
├── catalogue.ts # What a deployment can build with, as data
├── primitives/ # The starter library — a separate entry point
│ ├── tokens.ts # The only way a primitive names a colour or a length
│ ├── url.ts # The scheme allowlist every href and src is held to
│ ├── loom.page.ts # …and one module per primitive, named for its type
│ └── index.ts # The ten, and a registry over them
├── sdk/ # The framework SDK — a separate entry point
│ ├── definition.ts # The registration contract: definePrimitive
│ ├── registry.ts # The registry: resolver and validator in one object
│ ├── conformance.ts # Does a primitive spread loom.editable?
│ ├── audit.ts # The conformance check a host runs
│ ├── text.ts # Dictionaries, extraction, and what a translation covers
│ └── catalogue.ts # The registry, projected for consumers outside it
├── cli/ # Scaffolding — a separate entry point
│ ├── args.ts # The grammar: two commands, one option
│ ├── templates.ts # What gets written, as pure functions of a name
│ ├── plan.ts # What a command would write, decided before writing
│ ├── filesystem.ts # The one impure seam
│ ├── run.ts # parse → plan → write
│ ├── main.ts # The executable entry point
│ └── scaffold-fixture/ # `loom init`'s output, committed so it is typechecked
└── store/ # Persistence — a separate entry point
├── store.ts # TreeStore: the contract, and what it may refuse
├── memory.ts # The reference implementation: log plus snapshot
├── replay.ts # Folding the log, and auditing the snapshot against it
├── attribution.ts # Who placed each node, walked back out of the log
└── source.ts # The store as the renderer's TreeSourceThe workspace
The repo is a pnpm workspace with two packages. @jam-overture/loom is the root;
@loom/app is apps/loom, and it depends on the runtime as workspace:* so it
can only reach the published entry points — a deep import into src/ does not
resolve. When a surface needs something the public API does not expose, that is a
framework gap to close in the framework (see
0018).
apps/loom is one Next.js application with four surfaces in it, one route
group each (0067). A
route group contributes nothing to a URL, so what a group holds is the surface's
own root layout, its own stylesheet and its own code — and what a reader types is
the segment underneath it.
apps/loom/
├── proxy.ts # Sign-in, scoped to /portal and nothing else
└── app/
├── (marketing)/ # / — the site, composed in Loom
├── (docs)/ # /docs — prose, in MDX
├── (lessons)/ # /lessons — the course
└── (portal)/ # /portal — behind sign-in
├── globals.css # The silver design system, as tokens
├── layout.tsx # Topbar, rail, and the content column
├── _components/ # Topbar, sidebar, nav items
├── _lib/ # View models, the store, auth
└── portal/ # The routes themselvesThree of the four are built in Loom — they compose registered primitives and may not grow a component library of their own. The portal is the stated exception (0067): it is a tool for reviewing Loom trees rather than content built out of them.
pnpm verify at the root compiles the runtime, typechecks it, runs its tests,
then runs the application's typecheck, tests and build. The build is part of it
because prerendering is what proves the surfaces render.
The build comes first, and that order is load-bearing. src/cli/scaffold-fixture/
is checked-in scaffold output, and it imports @jam-overture/loom/react and
@jam-overture/loom/sdk the way a consumer does — through the exports map, which
points at dist/. Typechecking before the build fails on a clean clone with four
TS2307s, so the build has to have run. Keeping it in this order rather than
mapping those specifiers back to src/ means the typecheck also proves the
exports map resolves, which is the thing a consumer actually depends on.
The runtime compiles to dist/ and every entry point resolves there (0030), so
the application consumes it as an ordinary Node package rather than as TypeScript
source. apps/loom builds the runtime before its own typecheck and build,
because a clean clone has no dist and a build order that is not written down is
one that fails somewhere else.
Deploying
All four surfaces deploy to Vercel as one project from apps/loom. Settings, and
what a deployment can and cannot do before a backing store lands, are in
docs/deployment.md.
Learning Loom
lessons/ is a course on the ideas rather than the API. Each lesson starts from
a problem, shows why the obvious solution fails, and only then shows what Loom
does instead — with exercises you run. Start with the syllabus.
New to the codebase? Read lesson 01 before anything else; it is the thesis the rest of the system defends.
Decisions
decisions/ holds numbered architectural decision records — what was chosen,
what was rejected, and why. Start with the index.
Decision records and lessons answer different questions. A record says what we decided and what we rejected; it is written for someone deciding whether to change it. A lesson says why this is the right shape and how to think in it; it is written for someone learning. Neither substitutes for the other.
Commands
pnpm verifypnpm typecheck, pnpm test, pnpm test:coverage run the pieces individually.
Optional: the Anthropic interpreter
The model client is a one-method seam, and the Anthropic adapter is a separate entry point so that hosts bringing their own model never load it:
pnpm add @anthropic-ai/sdk # optional peer dependencyimport Anthropic from "@anthropic-ai/sdk"
import { modelInterpreter, randomIdFactory, systemClock } from "@jam-overture/loom"
import { anthropicModelClient } from "@jam-overture/loom/anthropic"
const interpreter = modelInterpreter({
client: anthropicModelClient(new Anthropic().messages),
idFactory: randomIdFactory,
clock: systemClock,
})ANTHROPIC_API_KEY is read from the environment by the SDK. One live smoke test
exercises the real API and skips when the key is absent, so pnpm verify is
green offline.
That test reads LOOM_ANTHROPIC_API_KEY first, falling back to
ANTHROPIC_API_KEY. The scheduled agent that develops this repo authenticates
with ANTHROPIC_API_KEY itself and strips it from the environment of the
processes it spawns, so a key set under the standard name never reaches Vitest
there. Set LOOM_ANTHROPIC_API_KEY in that environment; the standard name works
everywhere else.
Optional: the React renderer
The renderer is a separate entry point too, and react is an optional peer
dependency, so a host that only composes and stores trees never installs it:
pnpm add react # optional peer dependencyimport { renderRequest, staticPrimitiveResolver } from "@jam-overture/loom/react"
const rendered = await renderRequest(
{ treeId, editMode: false },
{ source, resolver: staticPrimitiveResolver({ "loom.page": Page, "loom.card": Card }) }
)A primitive receives three props — loom (its node id, type, edit-mode
decoration, its named regions, and — on the root node only — the mounted theme),
props (the node's props, unspread), and children. Rendering is
pure and total: it has no hooks and no IO, so it runs per request at the edge or
in a Server Component, and anything it could not render comes back in
diagnostics rather than as a thrown error.
Named regions
A primitive that treats one part of its contents differently from the rest
declares a slot, and places it. The element's slot children arrive on
loom.slots keyed by name, and are not in children — so a region is a
place the primitive chooses, rather than a position in a list that any move
could change (0051):
const Split = ({ loom, children }) => (
<div {...loom.editable} style={{ display: "flex", flexWrap: "wrap" }}>
<div>{loom.slots.start}{children}</div>
<div>{loom.slots.end}</div>
</div>
)A region the primitive does not place renders nothing. A host still projects
into a region by name through renderRequest's slots option, and what it
projects reaches the primitive the same way.
Wearing a theme
A theme is three registered ids — a palette, a font pack, a style preset —
carried on the root node under the runtime's reserved prop key, so changing one
is an ordinary configure the Gate weighs like any other change (0049):
import { createThemeRegistry } from "@jam-overture/loom"
import { THEME_PROP_KEY } from "@jam-overture/loom/react"
const root = {
kind: "element",
type: "loom.page",
props: { [THEME_PROP_KEY]: { palette: "bold", fontPack: "bold-sans", stylePreset: "airy-modern" } },
// …
}
const rendered = await renderRequest(request, { source, resolver, themes: createThemeRegistry() })The renderer resolves the selection, flattens it into --loom-* custom
properties, and hands them to the root primitive as loom.theme. A
primitive applies it as its style and reads colour, type and spacing back out
through var(--loom-accent) and friends, so it never learns which palette it is
wearing and a re-theme touches no node below the root:
const Page = ({ loom, children }) => (
<main {...loom.editable} style={loom.theme}>{children}</main>
)Prop keys beginning with loom: belong to the runtime: they are read at the
render seam and never reach a primitive or its schema, so a root primitive can
declare .strict() props and still wear a theme. A theme that cannot be
resolved renders unstyled with a diagnostic, never a blank page, and there is no
fallback theme — the page is a function of the tree, not of deployment config.
Both are argued in
0050.
Registering primitives
A primitive declares what a tree may set on it. The registry is both the renderer's resolver and its prop validator, because a component's narrowed prop type is only sound when the same object vetted the props:
import { catalogueOf, createPrimitiveRegistry, definePrimitive } from "@jam-overture/loom/sdk"
const card = definePrimitive({
type: "loom.card",
description: "A bounded block of related content",
props: z.object({ variant: z.enum(["outlined", "filled"]), elevation: z.number().optional() }).strict(),
component: ({ loom, props, children }) =>
<article {...loom.editable} data-variant={props.variant}>{children}</article>,
})
const registry = createPrimitiveRegistry([card]) // Result — refuses duplicates and bad identifiers
if (!registry.ok) throw new Error(describeRegistryError(registry.error))
const rendered = await renderRequest(request, {
source,
resolver: registry.value,
validator: registry.value,
})A node whose props fail its primitive's schema is omitted with an invalid-props
diagnostic, exactly as an unknown primitive is. Validation is a predicate, never a
codec: a primitive is handed the tree's props unchanged, so the page is a function
of the tree and not of which schema version a deployment happens to run.
catalogueOf(registry) projects the registry into plain data. Give it to
modelInterpreter({ …, catalogue }) and the model is told what it may build
instead of guessing at type names.
auditRegistry(registry) probes every primitive for the edit-mode contract and
reports which ones would be invisible to the portal. It is a function a host runs
in a test or a build step — registration itself never calls a primitive.
Registering part of a library
A primitive library is a set to choose from, not a set every deployment ships.
selectPrimitives takes the entries you want and refuses a name the library does
not carry — which a filter would not, and a deployment that quietly lost a
primitive finds out when a page fails to draw:
import { createPrimitiveRegistry, describeSelectionError, selectPrimitives } from "@jam-overture/loom/sdk"
import { STARTER_PRIMITIVES } from "@jam-overture/loom/primitives"
const chosen = selectPrimitives(STARTER_PRIMITIVES, ["loom.page", "loom.section", "loom.prose"])
if (!chosen.ok) throw new Error(describeSelectionError(chosen.error))
const registry = createPrimitiveRegistry(chosen.value)Why it is worth choosing: every registered primitive is a line in every
interpretation request. measureCatalogue(catalogueOf(registry)) says what a
vocabulary costs, whole and per entry, measured from the block that is actually
sent — so what did registering more cost is a number rather than a feeling
(0170).
There is no ceiling on how much a deployment may register, deliberately: how much
attention a model should spend on a vocabulary depends on the model and on the
latency you will accept, and both are yours.
The starter primitives
Not in the published package.
@jam-overture/loom/primitivesis a subpath of this workspace only. Installing@jam-overture/loomfrom the registry gets you the framework and none of what follows in this section; the starter library ships separately (0194). This is written for somebody working in this repository.
A deployment does not have to start from an empty registry. @jam-overture/loom/primitives
ships ten, ported from the Hermes predecessor and chosen to cover the primitive
contract rather than the catalogue:
import { createStarterPrimitiveRegistry } from "@jam-overture/loom/primitives"
const registry = createStarterPrimitiveRegistry([myOwnPrimitive]) // Result, like any other| | | |
| --- | --- | --- |
| loom.page | root, composes | Mounts the theme; one column, at a chosen measure |
| loom.section | composes, slot heading | A band of the page, with its heading placed above the content |
| loom.split | composes, slots start end | Two regions side by side, wrapping to one column with no media query |
| loom.stat-grid | composes | A responsive grid of loom.stat children |
| loom.stat | leaf | One figure, its label, and an optional caption |
| loom.heading | leaf | level sets both the outline and the size; the text is child nodes |
| loom.prose | leaf | A paragraph; the text is child nodes |
| loom.divider | leaf | Three genuinely different renderings, selected by one enum |
| loom.media | leaf | An image; alt text required unless it says it is decorative |
| loom.action | leaf | A call to action; its destination is scheme-checked |
Every one of them reads colour, type and spacing through var(--loom-*) and
hard-codes none of it, which is what makes a re-theme one configure on the
root. That is enforced by a test: the whole sample page is rendered under both
starter palettes, and the markup below the root has to be byte-identical and to
contain no literal colour.
Two rules govern the port, and the remaining sixty follow them:
- A repeated item is a child node; a fixed field is a prop
(0052).
Hermes held a stat list in an array field; here the grid is a primitive and
each stat is a node, so adding one is an
insertthe Gate weighs, the log attributes and the inverse removes. - A URL is checked against a scheme allowlist, never merely parsed
(0053).
z.string().url()acceptsjavascript:alert(1), and props in a Loom tree are AI-authored.
Scaffolding
loom init [--dir <directory>]
loom add primitive <type> [--dir <directory>]init writes a starter primitive, a generated registry, and a conformance test
that runs auditRegistry — so the check exists from the first commit rather than
being something to remember later. add primitive declares one more and
regenerates the registry from the directory's contents.
A primitive's module is named after its type verbatim (commerce.product-card.ts)
because that is what lets the registry be regenerated exactly; the registry is a
generated file and says so. Both are argued in
0015.
Nothing is written unless the whole command can complete: a clash with an existing file is reported before the first write, so a refused command leaves the directory as it was.
pnpm loom init
pnpm loom add primitive commerce.product-cardbin points at src/cli/main.ts, which runs through tsx — this package has no
build step, and Node's own type stripping does not resolve the .js import
specifiers the repo uses to their .ts sources. That makes tsx a dependency of
the executable rather than a convenience. When a build step exists, bin should
point at the emitted entry and the shebang should go.
The CLI is usable as library code too: runCli(argv, nodeFileSystem) from
@jam-overture/loom/cli, which is what its tests drive.
Daily reports
Each session's report and its diagram live in reports/.
