skip to the page

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.

A page with something to look at, press and openlive · rendered through the runtime

Vaughan & Rill

Bicycles built for one road

Hand-brazed frames, made to measure, delivered anywhere in the country.

See the frames

Before 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 question
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_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"
            }
          ]
        }
      ]
    }
  ]
}
Two sections, a call to action, a link, and two questions that open. Nothing on it is instrumented — the same tree is measured further down this page by reading its markup.

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 arrivedTaken?What the runtime says
A batch this page sentyes5 signals, at revision 0
A batch with nothing in itnosignals: Array must contain at least 1 element(s)
A kind nobody registerednosignals.0.kind: Invalid discriminator value. Expected 'viewed' | 'dwelled' | 'activated' | 'disclosed' | 'completed'
A signal carrying the words a reader sawnosignals.0: Unrecognized key(s) in object: 'label'
A page that never said which revisionnorevision: 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

WhereWhat it gains
Every element
here, the loom.section
  • data-loom-node="n_readersignals8"
  • data-loom-type="loom.section"
The root, as well
  • data-loom-tree="t_readersignals1"
  • data-loom-revision="0"
Everything else on the pagenothing — 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.

The same page, rendered with addressed: truenot broadcasting

Vaughan & Rill

Bicycles built for one road

Hand-brazed frames, made to measure, delivered anywhere in the country.

See the frames

Before 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 question

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

A real broadcaster on a real tree. Nothing is stored and nothing leaves this tab — every batch above is also on the page as a 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.