Where the content comes from
Every example on this site so far has had its words written into it. "Hello from a tree" is in the tree. The three features in the grid below are in the tree. Somebody typed them, or asked for them and the Gate let the change through, and there they sit.
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": []
}
]
}
]
}A real page is mostly not like that. A shop's list of services lives in the shop's database. It changes on a Tuesday afternoon because somebody added a price, and nobody proposes that change, reviews it or undoes it. It is not part of the page's history because it is not part of the page.
So a tree can ask for it instead. The page says put the services here, and your app answers when somebody loads it.
That is the whole of this page: the question, who answers it, and what happens when nobody can.
A binding is a question
Here is what asking looks like, on the node that wants the answer:
"loom:data": {
services: { source: "catalogue.services", params: { tag: "baking", limit: 3 } },
}
Three things and no more.
services is the name this node will read the answer under — your own word,
like a slot name. source names something your deployment registered; the
node cannot reach anything else. params is what to ask with.
Nothing about the reply is in there, and that is the point. A tree stays a document rather than a copy of your database: two deployments serving the same revision show the same page asking the same questions, and differ only where their data differs.
A whole page of them, read out of a real tree:
Every binding written into this page, in the order they appear
| On | Read as | From | Asked with |
|---|---|---|---|
| the services band | loom.data.services | catalogue.services | {"tag":"baking","limit":3} |
| the grid inside it | loom.data.services | catalogue.services | {"limit":3,"tag":"baking"} |
| the opening hours | loom.data.hours | shop.opening-hours | {} |
| the line about the shop | loom.data.bio | profile.field | {"field":"bio"} |
What the planner asks: 3 questions for 4 bindings
| Question | Asked with | Answers |
|---|---|---|
| catalogue.services | {"tag":"baking","limit":3} | the services band · services · and · the grid inside it · services |
| shop.opening-hours | {} | the opening hours · hours |
| profile.field | {"field":"bio"} | the line about the shop · bio |
Four bindings, three questions. The band and the grid inside it both want the shop's services, and both wrote the same thing with the keys the other way round — which is what happens when two changes are planned months apart. The planner canonicalizes the order, so that is one question and one answer shared between them.
Your half: registering a source
A source is the thing that answers. It has a name, a line about itself, what it will accept, what it promises to answer with, and the code that actually goes and gets it.
import { z } from "zod"
import { createDataRegistry, defineSource, describeDataRegistryError } from "@jam-overture/loom"
const services = defineSource({
id: "catalogue.services",
description: "What this shop offers, most popular first.",
params: z.object({
tag: z.string().min(1).max(40),
limit: z.number().int().min(1).max(12),
}),
answers: z.array(z.object({ name: z.string(), price: z.string() })),
adapter: {
fetch: async ({ params }) => {
const rows = await db.services(params.tag, params.limit)
return { ok: true, value: rows }
},
},
})
const built = createDataRegistry([services])
if (!built.ok) throw new Error(describeDataRegistryError(built.error))
const sources = built.value
If that shape looks familiar, it is meant to: it is definePrimitive with a
different noun. A deployment declares what exists, a registry is the allowlist,
and nothing in a tree can reach past it.
Two schemas, and they are checked at different moments. params is checked
before your adapter is called. answers is checked after it returns.
The first one is there because params live in the tree, and everything in a
tree got there through a proposal. Anything that can ask for a configure can
ask for different params. A source that accepted a free-form query string would
be handing that to whatever plans changes on your deployment — and a cap of
twelve in the schema is a query that never runs, where a cap of twelve inside
your adapter is a query that does.
The second is there because your adapter's type is a claim about a database, and the schema is the only thing that makes the claim true on the day somebody renames a column.
Asking, before anything is drawn
Rendering a tree is synchronous. It has no await in it anywhere, which is what
lets it run inside a Server Component and what makes two renders of one revision
agree. Going and getting somebody's services is the opposite of that.
So the asking happens before the walk, and it is two lines in whatever already loads the page:
import { resolveTreeData } from "@jam-overture/loom"
import { renderLoomTree } from "@jam-overture/loom/react"
const data = await resolveTreeData(page, { registry: sources })
const rendered = renderLoomTree(page, { resolver: primitives, validator: primitives, data })
resolveTreeData reads the tree, works out the questions, asks all of them at
once, and hands back the answers keyed the way the renderer wants them. Asking
them at once rather than one after another is the difference between a page
waiting for its slowest integration and a page waiting for all of them added up.
Forget the first line and nothing breaks silently: the render comes back with a
data-unresolved diagnostic against every bound node, because a page missing
the data it asked for should say so.
What comes back
What the host answered, read back the way a primitive reads it
loom.data.services on the services band, from catalogue.services
ready3 rows
[ { "name": "Wedding cakes", "price": "from £220" }, { "name": "Birthday cakes", "price": "from £45" }, { "name": "Cupcakes, by the dozen", "price": "from £18" } ]loom.data.services on the grid inside it, from catalogue.services
ready3 rows
The same answer as the services band, from the one question both of them asked.
loom.data.hours on the opening hours, from shop.opening-hours
readynothing in it
[]
loom.data.bio on the line about the shop, from profile.field
ready
"Baking out of the same kitchen on Mill Street since 2011."
The grid does not repeat the list. It says where its answer came from, because it is the same answer — one question, asked once, handed to both of the nodes that asked it.
Every binding gets one of exactly two things: ready, with a value, or
unavailable, with a reason. There is no third state and, in particular,
there is no way for an answer to be merely absent.
Look at the opening hours. This shop has not filled them in, so the source
answered ready with an empty list — not unavailable, and not nothing.
That distinction is the most important one on this page. "You have not added your opening hours yet" and "we could not reach your opening hours" are different sentences, shown to different people, doing different jobs. A shape that could not tell them apart would eventually show a reader the wrong one, and the wrong one tells somebody their data is gone.
When there is no answer
Seven ways a question goes unanswered, produced by causing all seven:
7 questions on one page, coming back with 6 different reasons — and, last, a declaration nobody could read
| What happened | Reason | What the runtime says | Your code |
|---|---|---|---|
| The database answered that it could not answer. | unavailable | the source could not be reached — the stock database is not accepting connections | the adapter answered |
| Nobody is signed in, and these are somebody's own orders. | refused | the source refused — nobody is signed in | the adapter answered |
| A column was renamed, so the source answered with something its own schema refuses. | invalid-answer | the source answered with something its own schema refuses — 0.body: Required | the adapter answered |
| The integration threw instead of answering. | adapter-threw | the source threw instead of answering — the courier's API answered 503 | the adapter answered |
| The plan asked for forty services, and the source accepts at most twelve. | invalid-params | the params in the tree are not what the source accepts — limit: Number must be less than or equal to 12 | the adapter was never called |
| The plan named a source this deployment never registered. | no-such-source | no source is registered for it — registered: catalogue.services, profile.field, shop.opening-hours, shop.stock, orders.mine, reviews.latest, delivery.slots, courier.tracking | the adapter was never called |
| The integration never replied at all, and the render stopped waiting. | unavailable | the source could not be reached — no answer in 5ms | the adapter never came back |
| The declaration in the tree is not a binding map at all. | misdeclared | services.source: expected dot-namespaced kebab-case, like "commerce.products" | the adapter was never called |
Two of those never reach your code at all. A source nobody registered and params the source refuses are both settled at the seam, which is what makes the schema worth writing. The other five are your deployment's own afternoon: a database that says it is down, an integration that never replies, a refusal, an answer that no longer matches its own schema, and an integration that threw instead of answering.
Two rows come back with the same reason, and the third column is the
difference. shop.stock answered — quickly, and with "I cannot answer" —
which is an integration doing its job badly but doing it. courier.tracking
never answered at all, and the sentence beside it was written by the runtime
when it stopped waiting. Both are unavailable on purpose: the remedy is the
same and so is the person who has to act. What you do about it is in
when nothing comes back, which is
about the ceiling that produced that row.
There is one more, not-resolved, and no single page can cause it: it means
the render was handed answers resolved for a different tree than the one it
drew. Nothing about your data is wrong. Whoever calls the render resolved one
plan and rendered another, and that is where the fix goes.
The integration that threw is worth a sentence on its own. An adapter is not Loom's
code — it is a query against somebody's database, or a call to an integration
having a bad day — so it is wrapped in the one try in the whole runtime. A
rejected promise from one source is one region of a page, never a page that
500s.
The last row in the table is not one of the six. It is what happens when the declaration in the tree is not a binding map at all — a page that came back from a database with something the seam cannot read. The whole map is refused together and the node renders without data, because answering half of a declaration nobody could read would be the hardest kind of failure to notice.
Reading it, in a primitive of your own
The answers arrive beside props rather than mixed into them, because the two
have different authors: props are in the tree, proposed and weighed and
attributed; data is your answer to a question the tree asked, and it belongs to
whoever runs the deployment.
import { createElement } from "react"
import { z } from "zod"
import { definePrimitive } from "@jam-overture/loom/sdk"
import type { LoomPrimitiveProps } from "@jam-overture/loom/react"
const rows = z.array(z.object({ name: z.string(), price: z.string() }))
export const serviceList = definePrimitive({
type: "shop.service-list",
description: "The shop's services, read from a catalogue.services binding.",
props: z.object({}).strict(),
slots: [],
component: ({ loom }: LoomPrimitiveProps<Record<string, never>>) => {
const answer = loom.data["services"]
if (answer === undefined || answer.status === "unavailable") {
return createElement("p", loom.editable, "We could not load our services just now.")
}
const services = rows.safeParse(answer.value)
if (!services.success) {
return createElement("p", loom.editable, "We could not load our services just now.")
}
if (services.data.length === 0) {
return createElement("p", loom.editable, "We have not listed any services yet.")
}
return createElement(
"ul",
loom.editable,
services.data.map((service) =>
createElement("li", { key: service.name }, `${service.name} — ${service.price}`)
)
)
},
})
loom.data is always there, empty for the overwhelming majority of nodes that
bind nothing, so a primitive reads loom.data.services without first proving the
map exists. The three endings in that component are the three cases in order:
could not be answered, answered with nothing, answered.
Writing a primitive is one command and one file, and what a primitive is made of is on Primitives and the registry.
Who may change a question
A binding decides which of your data appears on a public page. So it matters who
is allowed to change one — and repointing a binding is one configure against
one prop, which is the smallest change there is.
Here is that exact ask, judged twice, against two policies that differ by one line:
The same ask — point the band at somebody's orders — judged twice
The policy you get for free
Nothing said about your data — the runtime's own default.
Held for a person
repointed-binding · stakes high
repoints a binding: n_dataseam2.services from catalogue.services to orders.mine
One line added
refusalFloor is "high".
Refused outright — nobody is asked
stakes-at-refusal-floor · stakes high
repoints a binding: n_dataseam2.services from catalogue.services to orders.mine
The first column is a deployment that has said nothing about its own data, and the Gate still sends the change to a person. You do not have to ask for that: a binding is the runtime's own key, both sources were registered by you in either case, and which of your data comes out should not depend on who asked for it to change.
The second column has added one line:
refusalFloor: "high",
That is a deployment which has decided damage this size is not something it offers at all, and it gets a refusal rather than a question. The floor stays sovereign over the escalation — it is consulted first, so lowering it turns every held change of this weight into a refused one.
What your app owes a reader
Four things, and the first two are the ones people forget:
- A different sentence for empty and for broken. They arrive as different shapes precisely so you can tell them apart. Spending them on one message throws away the only thing the seam did for you.
- Something to look at either way. A node whose binding failed still renders. What it renders is your decision, and blank is a decision.
- A cap in every params schema. A binding is a prop like any other, and
anything that can propose a
configurecan change what you are asked for. - A line in your policy if the questions matter. One key, once, before somebody asks for the interesting one.