skip to the page

Quickstart

One file. One command. No database, no API key, no framework.

By the end of this page you will have done the three things Loom exists for, on your own machine:

  • registered a component of your own — a word nothing could say until you said it could,
  • drawn a page that was never written as markup, and
  • watched three people-sized requests hit that page and come back with three different answers: yes, ask a person first, and no.

The rest of this site takes each of those apart slowly. This page does all three at once, so that you have something running before you decide whether any of it is worth your afternoon.

What you need

A directory with nothing in it, and Node. Then one install:

$ pnpm add @jam-overture/loom @jam-overture/loom-primitives react react-dom zod

Two packages: the framework, and the starter library the file registers a primitive alongside. Installation says what each is for.

The file

Save it as quickstart.mts. The m matters, and it is the one detail worth reading before you paste: it is what tells Node the file is a module. A project that pnpm add just created does not say so anywhere, and the same file named quickstart.ts stops before it runs with a complaint about top-level await that will not tell you any of this.

quickstart.mtsread from the repository as this page built
// Loom in one file.
//
// Save this as `quickstart.mts` — the `m` is not decoration. It is what tells
// Node this file is a module, and a fresh project's package.json does not say
// so. Named `quickstart.ts` in a new directory, this stops before it runs with
// an error about top-level await that explains nothing.
//
//     pnpm add @jam-overture/loom @jam-overture/loom-primitives react react-dom zod
//     npx tsx quickstart.mts
//
// It registers a component of your own, writes a page down as data, draws that
// page into `quickstart.html`, and then lets somebody ask for three changes to
// it. Two of the three reach the page and one of them never will.
//
// Nothing here is a demo mode. It is the same runtime a deployment runs, with
// memory where a deployment would put a database.

import { writeFileSync } from "node:fs"
import { createElement } from "react"
import { renderToStaticMarkup } from "react-dom/server"
import { z } from "zod"

import {
  buildElement,
  buildText,
  createTree,
  createThemeRegistry,
  err,
  fixedPolicy,
  gatePolicySchema,
  ok,
  sequentialIdFactory,
  systemClock,
  type ChangeInterpreter,
  type LoomTree,
  type TreeOperation,
} from "@jam-overture/loom"
import { createStarterPrimitiveRegistry } from "@jam-overture/loom-primitives"
import { renderLoomTree, THEME_PROP_KEY, type LoomPrimitiveProps } from "@jam-overture/loom/react"
import { definePrimitive, describeRegistryError } from "@jam-overture/loom/sdk"
import { memoryTreeStore } from "@jam-overture/loom/store"
import {
  commitIntent,
  confirmHeld,
  describeWriteOutcome,
  memoryHoldStore,
  type WriteOutcome,
  type WritePath,
} from "@jam-overture/loom/write"

/** Node ids. Sequential here so that two runs of this file produce the same page. */
const ids = sequentialIdFactory("quickstart")

// ── 1. A word of your own ────────────────────────────────────────────────────
//
// A primitive is three things: a name a page may say, a schema its props must
// satisfy, and a component that draws it. Until this exists, nothing anywhere
// can propose a notice — that is the whole bargain, in one declaration.

const noticeProps = z.object({ text: z.string().min(1).max(160) }).strict()

const notice = definePrimitive({
  type: "app.notice",
  description: "A short standing note, at the end of a page. One sentence, never two.",
  props: noticeProps,
  slots: [],
  component: ({ loom, props }: LoomPrimitiveProps<z.infer<typeof noticeProps>>) =>
    createElement(
      "p",
      { ...loom.editable, style: { border: "1px solid", padding: "0.75rem" } },
      props.text
    ),
})

// The starter library plus yours. It refuses rather than repairs, so a clash of
// names is an error here and not a surprise at render time.
const built = createStarterPrimitiveRegistry([notice])

if (!built.ok) throw new Error(describeRegistryError(built.error))

const registry = built.value

// ── 2. The page, written down as data ────────────────────────────────────────
//
// No markup, no components, no file of JSX. Three nodes, each naming a
// primitive the registry knows and carrying props that primitive declared.

const headline = buildElement(ids, {
  type: "loom.heading",
  props: { level: 1 },
  children: [buildText(ids, "Hello from a tree")],
})

const sentence = buildElement(ids, {
  type: "loom.prose",
  children: [buildText(ids, "Nothing here was written as markup.")],
})

const root = buildElement(ids, {
  type: "loom.page",
  props: {
    width: "readable",
    fills: true,
    // A theme is three registered ids on the root, not a stylesheet you import.
    [THEME_PROP_KEY]: { palette: "minimal", fontPack: "minimal-sans", stylePreset: "precise" },
  },
  children: [headline, sentence],
})

const page = createTree(root, ids)

// ── 3. Drawing it ────────────────────────────────────────────────────────────
//
// Rendering is synchronous, pure, and never fails. It hands back an element and
// a list of what it could not honour — empty, on a healthy render.

const draw = (tree: LoomTree): string => {
  const rendered = renderLoomTree(tree, {
    resolver: registry,
    validator: registry,
    themes: createThemeRegistry(),
  })

  for (const diagnostic of rendered.diagnostics) {
    console.warn(`  render diagnostic: ${diagnostic.code} at ${diagnostic.nodeId}`)
  }

  writeFileSync(
    "quickstart.html",
    `<!doctype html><meta charset="utf-8"><title>Loom quickstart</title>${renderToStaticMarkup(rendered.element)}`
  )

  return `revision ${tree.revision}, in quickstart.html`
}

// ── 4. Where the page lives ──────────────────────────────────────────────────
//
// A tree store keeps the page and its history; a hold store keeps changes that
// are waiting for a person. Both are in memory here. `@jam-overture/loom/postgres`
// implements the same two contracts, and nothing below this line would change.

const store = memoryTreeStore()
const created = await store.create(page)

if (!created.ok) throw new Error(created.error.code)

// ── 5. The part that would be a model ────────────────────────────────────────
//
// An interpreter turns a sentence into a written-down plan. A deployment points
// this at a model. A lookup table is a perfectly good interpreter, and it is
// what keeps this file runnable with no API key: the runtime does not care
// where a proposal came from, only what it says.

const PLANS: Record<string, readonly TreeOperation[]> = {
  "Add a notice to the end of the page.": [
    {
      op: "insert",
      parentId: root.id,
      index: root.children.length,
      node: buildElement(ids, {
        type: "app.notice",
        props: { text: "Nobody typed this line into a file." },
      }),
    },
  ],
  "Make the headline smaller.": [
    { op: "configure", nodeId: headline.id, set: { level: 3 }, unset: [] },
  ],
  "Delete the headline.": [{ op: "remove", nodeId: headline.id }],
}

const interpreter: ChangeInterpreter = {
  interpret: (intent, tree) => {
    const operations = PLANS[intent.utterance]

    return Promise.resolve(
      operations === undefined
        ? err({ code: "refused", detail: "this quickstart only knows three sentences" })
        : ok({
            proposalId: ids.proposalId(),
            intentId: intent.intentId,
            delta: {
              deltaId: ids.deltaId(),
              treeId: tree.treeId,
              baseRevision: tree.revision,
              operations,
            },
            rationale: "planned by a lookup table, where a model would go",
            provenance: {
              origin: intent.origin,
              // Copied from the ask rather than invented — and left out rather
              // than set to undefined, which the runtime counts as different.
              ...(intent.actor === undefined ? {} : { actor: intent.actor }),
              interpreter: "quickstart/table",
              // A computed plan is not a guess, so it is not the model's record.
              authoredBy: "runtime" as const,
              confidence: 1,
              interpretedAt: systemClock.now(),
            },
          })
    )
  },
}

// ── 6. Who is allowed to say yes ─────────────────────────────────────────────
//
// The policy is yours, not the runtime's. This one names the headline worth
// protecting. A real deployment would name its checkout, its prices, its
// consent notice. Everything else is the default.
//
// Watch what one word buys: *reconfiguring* something protected asks a person,
// and *destroying* it is refused outright. Same primitive, two verdicts.

const path: WritePath = {
  store,
  holds: memoryHoldStore(),
  runtime: {
    interpreter,
    policySource: fixedPolicy(
      gatePolicySchema.parse({
        policyId: "quickstart",
        protectedPrimitiveTypes: ["loom.heading"],
      })
    ),
    events: { emit: () => undefined },
    clock: systemClock,
    idFactory: ids,
  },
}

// ── 7. Asking ────────────────────────────────────────────────────────────────
//
// One call. Read the head, plan, judge, and — only if the Gate allowed it —
// append to the log. There is no second route into the page.

/**
 * A write can end seven ways. Three of them are the whole story of the Gate,
 * and the runtime has a sentence ready for the other four.
 */
const explain = (outcome: WriteOutcome): string => {
  switch (outcome.kind) {
    case "committed":
      return `the page is now at revision ${outcome.tree.revision}`
    case "held":
      return `${outcome.held.disposition.reason.detail} — nothing has changed yet`
    case "refused":
      return outcome.disposition.reason.detail
    default:
      return describeWriteOutcome(outcome)
  }
}

const ask = async (utterance: string): Promise<void> => {
  const head = await store.head(page.treeId)

  if (!head.ok) throw new Error(head.error.code)

  const outcome = await commitIntent(path, {
    intentId: ids.intentId(),
    treeId: page.treeId,
    baseRevision: head.value.revision,
    origin: "user-instruction",
    actor: "you",
    utterance,
    observedAt: systemClock.now(),
  })

  console.log(`\n  "${utterance}"`)
  console.log(`   ${outcome.kind} — ${explain(outcome)}`)

  // A held change is not a refused one. It is waiting for a person, and saying
  // yes hands it back to the Gate to be judged again against the page as it now
  // stands — it does not overrule anything.
  if (outcome.kind === "held") {
    const answered = await confirmHeld(path, { proposalId: outcome.held.proposalId, actor: "you" })

    console.log(`   you said yes → ${answered.kind} — ${explain(answered)}`)
  }
}

console.log(`Drew the page: ${draw(page)}`)

await ask("Add a notice to the end of the page.")
await ask("Make the headline smaller.")
await ask("Delete the headline.")

// ── 8. What is on the page now ───────────────────────────────────────────────

const head = await store.head(page.treeId)

if (!head.ok) throw new Error(head.error.code)

console.log(`\nDrew it again: ${draw(head.value)}`)

const log = await store.revisions(page.treeId, { direction: "older", limit: 10 })

if (log.ok) {
  console.log("\nThe log — every change that reached the page, and who asked:")

  for (const entry of log.value.revisions) {
    const verbs = entry.delta.operations.map((operation) => operation.op).join(", ")

    console.log(`   ${entry.revision}. ${verbs} — asked by ${entry.provenance.actor ?? "nobody"}`)
  }
}

Run it

$ pnpm add @jam-overture/loom @jam-overture/loom-primitives react react-dom zod$ npx tsx quickstart.mts
Drew the page: revision 0, in quickstart.html
 
"Add a notice to the end of the page."
committed — the page is now at revision 1
 
"Make the headline smaller."
held — touches protected loom.heading — nothing has changed yet
you said yes → committed — the page is now at revision 2
 
"Delete the headline."
refused — destroys protected loom.heading; touches protected loom.heading; restructures at depth 1
 
Drew it again: revision 2, in quickstart.html
 
The log — every change that reached the page, and who asked:
1. insert — asked by you
2. configure — asked by you
It also writes quickstart.html beside the file you ran. Open it — that is the page, drawn from the tree, twice.

What just happened

Each step of the file, in the order it runs them. Nothing here is a special mode — it is the same runtime a deployment runs, with memory where a deployment would put a database.

1 — You added a word. definePrimitive gives a name (app.notice), a schema for what it takes, and a React component that draws it. Before that line, nothing anywhere in the system could propose a notice. That is the deal Loom asks you to take, and it is the whole of it: AI can only say things your registry has words for.

2 — You wrote a page down as data. No JSX file, no template. Three nodes: a page, a heading, a sentence. Each names a primitive the registry knows and carries props that primitive declared. It is JSON — you could print it, store it, send it over a wire, read it back.

3 — You drew it. renderLoomTree turns the data into React elements. It is synchronous and it never fails: anything it could not honour comes back beside the element in a list of diagnostics, and the run prints any it gets. On a healthy render that list is empty, which is why you saw none.

4 — You gave the page somewhere to live. A tree store keeps the page and its history; a hold store keeps changes that are waiting for a person. Both are in memory here. @jam-overture/loom/postgres implements the same two contracts, and nothing after that line in the file would change.

5 — You filled the seam where a model goes. An interpreter turns a sentence into a written-down plan of operations. A deployment points that at a model. This one is a lookup table — which is why the file runs with no API key, and which is the point worth taking away: the runtime does not care where a proposal came from, only what it says.

6 — You wrote the rules. The policy is yours, not the runtime's. This one names the heading as the thing worth protecting. A real deployment would name its checkout, its prices, its consent notice.

7 — You asked. commitIntent is one function and one route in: read the page as it stands now, plan, judge, and — only if the Gate allowed it — append to the log. There is no second way in, which is why the log can be trusted.

8 — You read the log. Two rows, because two of the three asks reached the page. The refused one is not in it: the log records what happened, not what was asked for.

Three asks, three answers

The interesting part is that all three requests were reasonable, and they did not get the same answer.

"Add a notice to the end of the page." → committed. A new node at the end of a page threatens nothing, so the Gate said yes on its own and the page grew. Note which node it added: one of yours. Had you not written step 1, this sentence could not have been said at all.

"Make the headline smaller." → held. You named headings protected, so changing one is not the Gate's to wave through. It wrote the proposal down, put it aside and stopped. Nothing had changed on the page at that moment — a hold is not a slow yes.

Then confirmHeld answered it. That is not a way around the Gate: it hands the same change back to be judged again, against the page as it now stands, and only then is it applied. A change the Gate would refuse today stays refused, however emphatically you say yes.

"Delete the headline." → refused. Destroying the protected thing is a step further than shrinking it, and there is no version of it the Gate will offer. The sentence it prints is its own — destroys protected loom.heading — and it is the sentence you would put in front of whoever asked.

Things worth breaking

The file is short enough to vandalize, and three edits teach more than reading it twice. Each is one line.

Empty the protected list. Change protectedPrimitiveTypes: ["loom.heading"] to [] and run it again. All three asks are committed, including the deletion, and the page ends at revision 3. Nothing about the Gate changed — your policy did. That is the point of a policy being a host's choice.

Break a prop. Make the notice's text longer than the 160 characters its schema allows. The change is still committed, and the render prints invalid-props: the node is drawn without props rather than the page being blanked. It is worth knowing which seam caught it, because it is not the one most people guess — see below.

Add a fourth sentence to the lookup table that the three existing ones do not cover, and ask for it. The interpreter declines, and the run reports not-interpreted — a change nobody could plan is a different ending from a change that was planned and refused.

Where to go next

You have now done, in one file, what the six pages of this section do slowly and properly. If it worked, the slow version is worth the hour: Installation is step one, and Introduction lays out the route.

If you would rather go straight at the part you just skipped over: registering primitives properly is Primitives and the registry, deciding what may be touched is What AI may change, and the three answers above are What the Gate decides.