What your readers do
Everything on this site so far has moved in one direction. Somebody asks for a change, the runtime writes it down, the Gate weighs it, and the page becomes a new revision of itself.
This page is the other direction: the page saying something back.
Here is an ordinary page. Nothing is measuring it.
Vaughan & Rill
Bicycles built for one road
Hand-brazed frames, made to measure, delivered anywhere in the country.
See the framesBefore you order
How long does a frame take?
Between nine and fourteen weeks, depending on the finish you choose.
Can I be measured remotely?
Yes. We send a fitting kit and a short video call does the rest — most people never come to the workshop.
Anything else, ask us.
Send a questionEvery 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_readersignals20",
"type": "loom.page",
"props": {
"fills": true,
"width": "readable",
"loom:theme": {
"palette": "minimal",
"fontPack": "minimal-sans",
"stylePreset": "precise"
}
},
"children": [
{
"kind": "element",
"id": "n_readersignals8",
"type": "loom.section",
"props": {
"tone": "surface",
"width": "readable",
"eyebrow": "Vaughan & Rill"
},
"children": [
{
"kind": "slot",
"id": "n_readersignals3",
"name": "heading",
"children": [
{
"kind": "element",
"id": "n_readersignals2",
"type": "loom.heading",
"props": {
"level": 2
},
"children": [
{
"kind": "text",
"id": "n_readersignals1",
"value": "Bicycles built for one road"
}
]
}
]
},
{
"kind": "element",
"id": "n_readersignals5",
"type": "loom.prose",
"props": {
"measured": true
},
"children": [
{
"kind": "text",
"id": "n_readersignals4",
"value": "Hand-brazed frames, made to measure, delivered anywhere in the country."
}
]
},
{
"kind": "element",
"id": "n_readersignals7",
"type": "loom.action",
"props": {
"href": "https://example.com/frames",
"variant": "primary",
"scale": "small"
},
"children": [
{
"kind": "text",
"id": "n_readersignals6",
"value": "See the frames"
}
]
}
]
},
{
"kind": "element",
"id": "n_readersignals19",
"type": "loom.section",
"props": {
"tone": "canvas",
"width": "readable"
},
"children": [
{
"kind": "slot",
"id": "n_readersignals11",
"name": "heading",
"children": [
{
"kind": "element",
"id": "n_readersignals10",
"type": "loom.heading",
"props": {
"level": 2
},
"children": [
{
"kind": "text",
"id": "n_readersignals9",
"value": "Before you order"
}
]
}
]
},
{
"kind": "element",
"id": "n_readersignals14",
"type": "loom.faq-list",
"props": {
"columns": "one",
"width": "readable"
},
"children": [
{
"kind": "element",
"id": "n_readersignals12",
"type": "loom.faq",
"props": {
"question": "How long does a frame take?",
"answer": "Between nine and fourteen weeks, depending on the finish you choose."
},
"children": []
},
{
"kind": "element",
"id": "n_readersignals13",
"type": "loom.faq",
"props": {
"question": "Can I be measured remotely?",
"answer": "Yes. We send a fitting kit and a short video call does the rest — most people never come to the workshop."
},
"children": []
}
]
},
{
"kind": "element",
"id": "n_readersignals16",
"type": "loom.prose",
"props": {
"tone": "muted",
"size": "small"
},
"children": [
{
"kind": "text",
"id": "n_readersignals15",
"value": "Anything else, ask us."
}
]
},
{
"kind": "element",
"id": "n_readersignals18",
"type": "loom.link",
"props": {
"href": "https://example.com/contact",
"tone": "accent"
},
"children": [
{
"kind": "text",
"id": "n_readersignals17",
"value": "Send a question"
}
]
}
]
}
]
}A person reads that. They linger on the first section, press See the frames, scroll down, open a question. Then they leave.
Your app knows none of it. It served some HTML and the conversation ended.
Reader signals are how a Loom page tells you what happened to it: which part someone looked at, what they stayed on, what they pressed, what they opened and what they finished. A short list of things, and nothing else.
What a page may say
Each one below says what it means and, behind a line, what it does not.
That second line is not hedging, and it is worth a paragraph before you read any
of them. Every one of these names is shorter than the fact it stands for — a
name has to be, or it would not be a name — and the difference between the two is
where a report built on them goes quietly wrong. completed is the plainest
case: a page can see a form let go, and it cannot see whether anything, anywhere,
accepted it. So the name is the short version and the line under it is the whole
of what was observed.
The whole vocabulary: 5 kinds, and nothing else a page may say
viewedIt came into view. Once, the first time, for the life of the page.
On the page above: The reader scrolled far enough for the questions band to be on screen.
It does not mean anybody read it. Enough of the element was on screen, in a tab that was in front — so a reader who scrolled straight past is counted exactly like one who stopped, and a node sitting on screen in a tab nobody is looking at is not counted at all.
at — when it happened
{"kind":"viewed","nodeId":"n_readersignals14","type":"loom.faq-list","at":1757720400000}dwelledIt was on screen this long, since the last batch went out.
On the page above: They stayed on the first section for eleven seconds before scrolling.
It does not mean time spent reading. A tab nobody has in front of them accrues nothing, which is as close to attention as a browser gets; a window left open behind another window goes on counting.
ms — milliseconds, this batch only
{"kind":"dwelled","nodeId":"n_readersignals8","type":"loom.section","ms":11000}activatedA reader used a link, a button or a field inside it.
On the page above: They pressed “See the frames”.
It does not mean the press did anything. The page is watched and the outcome is not, so a control whose handler cancelled it, failed, or did nothing at all reports the same signal as one that worked.
at — when it happened
{"kind":"activated","nodeId":"n_readersignals7","type":"loom.action","at":1757720411000}disclosedA region was opened, or closed.
On the page above: They opened “How long does a frame take?”.
It does not mean a reader opened it. It is read off the region's own state changing, and your own code changing it — a link that arrives with an answer already expanded, an “open all” — looks identical to a person doing it.
open — true for opened, false for closed
{"kind":"disclosed","nodeId":"n_readersignals12","type":"loom.faq","open":true,"at":1757720418000}completedA form inside it was submitted, and the browser let it go.
On the page above: Nothing on the page above can produce one: there is no form in it.
It does not mean a server accepted it, and it does not mean every submission is counted. The broadcaster watches the page and never the reply: it reports a form the page let go with its constraints satisfied, so a form posted with fetch — which is most forms in a client-rendered app — reports nothing at all.
at — when it happened
{"kind":"completed","nodeId":"n_readersignals8","type":"loom.section","at":1757720419000}
(Different from what a signal does not carry, further down. That one is about what is deliberately left out of a signal, and the reason is privacy. This one is about what a browser is able to see at all.)
That is the whole vocabulary, and there is no way to add to it from your own code: a new kind is a change to the runtime and to the record that settles it (0136), for the same reason there are four operations on a tree and not five.
A closed list is worth more than it looks. It means anything receiving these can be written once, against shapes it will still be handling next year — and it means nobody can quietly start sending something else through the same pipe.
The best evidence that the list is really closed is a kind that was asked for and
refused. hovered was: it does not exist on a touchscreen, so it would
measure phone readers as having wanted nothing; it fires continuously, which is
the one thing this seam promises not to do to a reader's browser; and what anyone
actually wants from it is hovered with intent — held for long enough to mean
something — which is a different measurement wearing the same name. A list that
takes every reasonable suggestion is not closed, it is just short so far.
What a signal does not carry
Look at those again. A signal names a node and its type. It does not carry the heading the reader saw, the URL the link pointed at, what they typed into a field, or anything at all about the person.
That is not an omission somebody would fix later. It is checked:
parseReaderSignalBatch, given one batch a page sent and four things that were not one
| What arrived | Taken? | What the runtime says |
|---|---|---|
| A batch this page sent | yes | 5 signals, at revision 0 |
| A batch with nothing in it | no | signals: Array must contain at least 1 element(s) |
| A kind nobody registered | no | signals.0.kind: Invalid discriminator value. Expected 'viewed' | 'dwelled' | 'activated' | 'disclosed' | 'completed' |
| A signal carrying the words a reader saw | no | signals.0: Unrecognized key(s) in object: 'label' |
| A page that never said which revision | no | revision: Required |
The fourth row is the interesting one. A signal with the label of the button on it is refused, not trimmed — the whole batch is rejected, and your endpoint tells whatever sent it so.
Two reasons, and the second is the one people miss.
A record of what a person read is a record of that person. Loom keeps the same rule for the words somebody typed into a change request (0023). The moment signals could carry content they would become something you need a lawful basis to collect, and every host would inherit that whether they wanted it or not.
The content is already somewhere better. Every batch names a tree and a revision. The words that were on screen are in that revision of that tree, and they will still be there after ten more changes have been applied. A signal that copied them would be a second copy that goes stale, and this is a site with opinions about second copies.
That is also why the revision is on the batch rather than on each signal. A
rendered page is one revision of one tree; "the fourth section" stops meaning
anything the moment a proposal moves it, and n_readersignals8 at revision 0
means exactly one thing forever.
One delivery: 5 signals about t_readersignals1 at revision 0
{
"treeId": "t_readersignals1",
"revision": 0,
"sentAt": 1757720420000,
"signals": [
{
"kind": "viewed",
"nodeId": "n_readersignals14",
"type": "loom.faq-list",
"at": 1757720400000
},
{
"kind": "dwelled",
"nodeId": "n_readersignals8",
"type": "loom.section",
"ms": 11000
},
{
"kind": "activated",
"nodeId": "n_readersignals7",
"type": "loom.action",
"at": 1757720411000
},
{
"kind": "disclosed",
"nodeId": "n_readersignals12",
"type": "loom.faq",
"open": true,
"at": 1757720418000
},
{
"kind": "completed",
"nodeId": "n_readersignals8",
"type": "loom.section",
"at": 1757720419000
}
]
}Turning it on is two steps
Nothing above happens by default. A page that never asks broadcasts nothing, and the library renders no script of its own.
One: let the page say which node is which
A published Loom page deliberately carries no identity in its markup. Edit mode adds it, and edit mode promises that the markup is otherwise byte-identical (0010) — so a click on a live page lands on an element that says nothing about which node it belongs to.
addressed: true asks for the identity and nothing else edit mode means:
import { renderLoomTree } from "@jam-overture/loom/react"
const rendered = renderLoomTree(tree, {
resolver: registry,
validator: registry,
themes,
addressed: true,
})
What addressing writes on a page of 12 elements
| Where | What it gains |
|---|---|
| Every element here, the loom.section |
|
| The root, as well |
|
| Everything else on the page | nothing — 815 bytes of attributes in total |
Those four attribute names are the whole of it. Take them back out of the document and you have the unaddressed page, byte for byte — which is checked by rendering it both ways.
Two attributes per decorated element, and the tree and revision on the root. That is the entire difference, and both of those are identifiers the tree already assigned — neither is content.
Two: start the broadcaster, in the browser
"use client"
import { broadcastReaderSignals } from "@jam-overture/loom/signals/broadcast"
import { useEffect, useRef } from "react"
export const Measured = ({ children }: { readonly children: React.ReactNode }) => {
const frame = useRef<HTMLDivElement>(null)
useEffect(() => {
const root = frame.current?.querySelector("[data-loom-tree]")
if (!(root instanceof Element)) return
const started = broadcastReaderSignals(root, {
kinds: ["dwelled", "activated"],
types: { dwelled: ["loom.section"], activated: ["loom.link", "loom.action"] },
send: (batch) => {
navigator.sendBeacon("/api/signals", JSON.stringify(batch))
},
})
if (!started.ok) {
console.warn(started.error.detail)
return
}
return started.value.stop
}, [])
return <div ref={frame}>{children}</div>
}
It returns a Result, and the failure is worth handling. An unaddressed page
is the one misconfiguration nobody would ever notice: a broadcaster that quietly
sent nothing looks exactly like a page nobody visited. So starting one on a page
rendered without addressed: true is an error you get back, with a sentence
saying what to do about it.
The same page, broadcasting
Everything above this line was produced on a server. This cannot be — a broadcaster's whole subject is a browser with somebody in front of it.
So here is the same tree again, rendered addressed: true, with a real
broadcaster attached to it. Scroll it, press the button, open a question.
Nothing is stored and nothing leaves this tab.
addressed: truenot broadcastingVaughan & Rill
Bicycles built for one road
Hand-brazed frames, made to measure, delivered anywhere in the country.
See the framesBefore you order
How long does a frame take?
Between nine and fourteen weeks, depending on the finish you choose.
Can I be measured remotely?
Yes. We send a fitting kit and a short video call does the rest — most people never come to the workshop.
Anything else, ask us.
Send a questionScroll it, press See the frames, open a question.
Nothing delivered yet. A batch goes out every second, and only when there is something in it — a page nobody is reading sends nothing at all.
loom:signals event, which is how anything else on a page reads them without being wired to the broadcaster.That one asks for four kinds, each about a different part of the page:
viewed for the two sections and the questions band, dwelled for the sections
only, activated for the link and the call to action, disclosed for the
questions. Press or scroll something that is not on one of those lists and no
batch mentions it.
Choosing what you measure
kinds says which to report. types says which primitives to report them
about — and it can differ per kind, which is usually what you want:
kinds: ["viewed", "dwelled", "activated"],
types: {
dwelled: ["loom.section", "loom.hero"],
activated: ["loom.link", "loom.action"],
},
A kind types does not name is reported for every type. A plain list instead of
an object applies to every kind.
The reason to bother is arithmetic. dwelled fires for every watched node on
screen, every batch. A page asking for time on screen across every link and
badge on it produces something like ten signals a second, most of which nobody
will ever look at — paid for in the reader's browser, on the wire, and again in
whatever stores them.
Reading a batch back
A batch arrives at your endpoint as JSON from a browser, which is to say from somebody you have never met. Parse it:
import { parseReaderSignalBatch } from "@jam-overture/loom/signals"
export const POST = async (request: Request): Promise<Response> => {
const read = parseReaderSignalBatch(await request.json())
if (!read.ok) {
return Response.json({ issues: read.error.issues }, { status: 400 })
}
await store(read.value)
return new Response(null, { status: 204 })
}
Invalid input is a value, not a throw — the same shape as every other refusal in this runtime. The table further up is that function's real answers to five real inputs.
What this does not do yet
This page documents a seam that shipped on 12 September 2026, and being straight about its edges is more useful than implying it is finished.
Nothing stores or interprets these. Where batches live, how they are aggregated, how long they are kept, and how a signal becomes a proposed change are all explicitly undecided (0136). The runtime broadcasts; everything after that is yours today.
A broadcaster watches the nodes that were there when it started. It finds the
addressed elements under its root once. A band that streams in behind a Suspense
boundary, or a list that grows after hydration, is never watched for viewed or
dwelled — though presses and disclosures inside it still report, because both
are read from the root at the moment they happen.
A new revision is a new broadcast. After a client-side navigation, or after a
change is applied and the page re-renders at a new revision, stop() the
broadcaster and start another. One left running on the old root keeps naming the
old revision, which is a subtler problem than reporting nothing.