skip to the page

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 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.

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

OnRead asFromAsked with
the services bandloom.data.servicescatalogue.services{"tag":"baking","limit":3}
the grid inside itloom.data.servicescatalogue.services{"limit":3,"tag":"baking"}
the opening hoursloom.data.hoursshop.opening-hours{}
the line about the shoploom.data.bioprofile.field{"field":"bio"}

What the planner asks: 3 questions for 4 bindings

QuestionAsked withAnswers
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 happenedReasonWhat the runtime saysYour code
The database answered that it could not answer.unavailablethe source could not be reached — the stock database is not accepting connectionsthe adapter answered
Nobody is signed in, and these are somebody's own orders.refusedthe source refused — nobody is signed inthe adapter answered
A column was renamed, so the source answered with something its own schema refuses.invalid-answerthe source answered with something its own schema refuses — 0.body: Requiredthe adapter answered
The integration threw instead of answering.adapter-threwthe source threw instead of answering — the courier's API answered 503the adapter answered
The plan asked for forty services, and the source accepts at most twelve.invalid-paramsthe params in the tree are not what the source accepts — limit: Number must be less than or equal to 12the adapter was never called
The plan named a source this deployment never registered.no-such-sourceno source is registered for it — registered: catalogue.services, profile.field, shop.opening-hours, shop.stock, orders.mine, reviews.latest, delivery.slots, courier.trackingthe adapter was never called
The integration never replied at all, and the render stopped waiting.unavailablethe source could not be reached — no answer in 5msthe adapter never came back
The declaration in the tree is not a binding map at all.misdeclaredservices.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:

  1. 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.
  2. Something to look at either way. A node whose binding failed still renders. What it renders is your decision, and blank is a decision.
  3. A cap in every params schema. A binding is a prop like any other, and anything that can propose a configure can change what you are asked for.
  4. A line in your policy if the questions matter. One key, once, before somebody asks for the interesting one.