What your app has to do
The last four pages were about the runtime making decisions. This one is about the code you write around it — which is smaller than you would expect, and has exactly one shape.
Loom does not run your application. It has no routes, no login, no session and no opinion about your database. What it has is one function that changes a page. Your job is to call it when somebody asks for something, and to do something sensible with each of the ways it can answer.
One call, seven answers. That is the whole of the surface.
Hello from a tree
Nothing here was written as markup.
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_firsttree5",
"type": "loom.page",
"props": {
"fills": true,
"width": "readable",
"loom:theme": {
"palette": "minimal",
"fontPack": "minimal-sans",
"stylePreset": "precise"
}
},
"children": [
{
"kind": "element",
"id": "n_firsttree2",
"type": "loom.heading",
"props": {
"level": 1
},
"children": [
{
"kind": "text",
"id": "n_firsttree1",
"value": "Hello from a tree"
}
]
},
{
"kind": "element",
"id": "n_firsttree4",
"type": "loom.prose",
"props": {},
"children": [
{
"kind": "text",
"id": "n_firsttree3",
"value": "Nothing here was written as markup."
}
]
}
]
}The chips on that example go through the same function a deployment calls, and so does every other example on this site. Nothing here is a demonstration mode.
The three things a change needs
commitIntent takes two arguments. The second is the ask. The first is called
the write path, and it is the three things that have to exist before a page
can change at all:
import { fixedPolicy, randomIdFactory, systemClock } from "@jam-overture/loom"
import { postgresHoldStore, postgresTreeStore } from "@jam-overture/loom/postgres"
import { commitIntent, type WritePath } from "@jam-overture/loom/write"
const path: WritePath = {
store: postgresTreeStore(db),
holds: postgresHoldStore(db),
runtime: {
interpreter,
policySource: fixedPolicy(policy),
events,
clock: systemClock,
idFactory: randomIdFactory,
},
}
The store is where the page lives, and where every change ever applied to it is kept. The holds are where a change waits when the Gate decides a person has to answer it — a separate place, because a change that has not happened cannot go in the history of things that did. The runtime is the thing that plans a change and the rules that judge it: an interpreter, usually a model, and your policy.
Build it once, where your app starts, and hand it to every request.
An ask names the page, and which version of it
An intent is the ask. It is a sentence, plus enough context to judge it:
const intent: EditIntent = {
intentId: randomIdFactory.intentId(),
treeId: page.treeId,
baseRevision: page.revision,
origin: "user-instruction",
actor: session.userId,
utterance: "Make the heading on this page smaller",
observedAt: systemClock.now(),
}
const outcome = await commitIntent(path, intent)
Two of those fields are the ones people leave out, and both of them cost something real.
baseRevision is the version of the page they were looking at when they
asked. Not the version your server has now — the one on their screen. It is what
lets the runtime notice that the page moved under them, and refuse the ask
before it spends a model call on a plan that could only have been thrown away.
actor is who is asking, by whatever name your app knows them. It is
recorded on the change, and on a change a person had to allow it is recorded
separately from who allowed it. A history where those two are the same field
makes every confirmed change look like somebody waving through their own
request.
Seven ways that call can end
Here they are, and none of them was typed. Each card below was produced by sending an ask through the write path as this page was built, and the middle line is the sentence the runtime itself returned about what happened.
It happened
committed
The Gate allowed the change, the page took it, and the log has a new row.
What the runtime says
applied at revision 1
What your app does. Show the page the store handed back — not the one you had — and remember the revision it is now at. That number is what the next ask will be written against.
Somebody has to say yes
held
The change touches something this deployment called consequential, so the Gate put it in custody instead of applying it.
What the runtime says
held for confirmation: touches protected loom.heading
What your app does. Nothing has changed yet, and the answer arrives later — from a different person, in a different request. Put the proposal in front of a reviewer with the Gate's reason, and keep its id: that id is the whole of what a confirmation sends.
No, and confirming will not help
refused
The Gate judged the change too costly to offer at all. Destroying something protected is the plainest case.
What the runtime says
refused: destroys protected loom.heading; touches protected loom.heading; restructures at depth 1
What your app does. Tell the asker what the Gate said, in the Gate's own words. There is nothing to undo — a refusal never touched the page — and there is no button that turns this into a yes.
Nothing was planned
not-interpreted
The step that guesses could not answer: the model timed out, the credential is missing, or it declined.
What the runtime says
the model could not be reached, and may answer later: the model did not answer in time
What your app does. This is the one ending that is not about the change, so do not report it as a refusal. The error says which of the five actors has to do something — wait, fix a credential, or file a bug — and the page is untouched either way.
The plan did not fit the page
not-applicable
A delta arrived naming a node that is not there. A model that invents an id produces exactly this.
What the runtime says
the proposal did not apply: No node n_gone in this tree.
What your app does. The asker did nothing wrong and the Gate never got a look in — the change failed before it was judged. Count these: a rising number is a planner going wrong, not readers asking for the impossible.
The page moved while they were looking at it
not-written
The ask named revision 0, and by the time it arrived somebody else's change had made the page revision 1.
What the runtime says
t_firsttree1 moved on: the delta applies to revision 0, but head is 1
What your app does. Show them the page as it stands and let them ask again. This one is refused before the model is called, so a stale ask costs nothing — which is why every ask has to carry the revision it was written against.
That answer arrived twice
not-answerable
Two tabs, one held change, both pressing yes. The first answer applied it; the second found nothing in custody.
What the runtime says
no proposal is held under p_endanswer11; it may already have been answered
What your app does. Treat it as already answered rather than as an error to retry. Custody is taken, not read, so the change cannot apply twice however many times the button is pressed — that is a property of the hold store and not a rule your handler has to enforce.
Three of these are the Gate speaking: it happened, somebody has to say yes, no. Those are the ones your product is about, and the middle one is the reason Loom exists — software that can only allow or forbid has to decide at build time which changes are safe, and it will be wrong.
The other four are the world being ordinary. A model times out. A planner names
a node that is not there. Two people edit the same page. Somebody double-clicks.
None of them is exotic, and each one wants a different sentence in front of a
person, which is why the runtime keeps them apart instead of handing you one
error to shrug at.
Answering a change that is waiting
A held change is not applied. It is in custody, keyed by the proposal's own id, and the answer arrives later — from a different person, in a different request, possibly on a different server. So your app needs a second door:
import { confirmHeld, discardHeld } from "@jam-overture/loom/write"
const yes = await confirmHeld(path, { proposalId, actor: reviewer.id })
const no = await discardHeld(path, { proposalId, actor: reviewer.id })
A confirmation sends an id and a name, and never the change itself. The change is already on your server; a browser that could post one would be a browser that could author one, and then the Gate would be judging whatever the browser felt like sending.
To build a review queue, ask the hold store what is waiting for a page:
holds.forTree(treeId) returns them oldest first, each carrying the Gate's
reason rather than its verdict — because a reviewer needs to know why this was
worth stopping, and "requires confirmation" tells them nothing.
Two things about answering that you do not have to build:
Saying yes is not a way past the Gate. The change is judged again, against the page as it stands at the moment of the answer. If the page moved in a way that makes the change unsafe, it is refused, and the person who said yes was answering the question rather than overruling it.
An answer can only happen once. Releasing a proposal from custody is a take, not a read — it removes and returns in one step — so two tabs pressing yes cannot both apply the same change. That is a property of the store, not a rule your handler has to remember.
What you do not have to write
No undo route. Undo goes through this same function, as an ordinary proposal
from an interpreter that has nothing to guess. Call revertRevision and the
change is judged, held or refused like any other, and appended as a new
revision.
No check that a change was judged. There is one function that appends to a page's history, and it is the same function that runs the Gate. Not tidiness — it is what makes "every change was judged" a property of the system rather than a rule everybody has to remember.
No locking. Every ask names the revision it was written against, so two people editing the same page do not need a lock; the second one is told the page moved and gets to ask again against what is actually there.
Where this site does exactly this
Every example on these pages opens a store in your browser tab and sends its
asks through commitIntent. The site has no privileged path into the runtime,
and it is worth knowing that the code doing it is about a hundred lines: build a
runtime around the interpreter, build the intent, call the function, and render
whatever came back.
The interpreter behind these chips is deterministic rather than a model, which is the one thing here a real deployment does differently — dozens of examples that must behave identically on every visit is not a thing to point at a model. Everything downstream of that seam cannot tell the difference, which is exactly what the seam is for.