The JS client

A notebook built for the browser (--target=wasm) is not a page you can only look at — it is a component a host page can drive. Everything a stranger’s page needs is already one plain-data object, globalThis.notebook, published by the WASM build. The JS client is the optional, typed way to hold that object.

You never have to import it. globalThis.notebook is callable directly. Import the client and you trade a few lines of hand-rolled glue for editor autocomplete, a structural view of the notebook’s shape, and — the reason it exists — typed value events: a program computes on the numbers a cell produces, not on the text they render to.

There is no build step and no toolchain. client/notebook.js is pure ESM with JSDoc types; it runs unmodified in a browser (<script type=module>) and in Node, and a hand-written client/notebook.d.ts gives TypeScript first-class types. Nothing here is compiled, bundled, or published to npm — the same importless ethos as the notebooks themselves.

See it work

The page below is not the notebook’s built-in UI. It loads the capacity notebook’s WASM, then draws its own two panels and speaks to the notebook only through the client. The left control is one this host page rendered itself; the right panel is a second computation running on the notebook’s typed output stream. Drag the slider — the fleet’s hourly cost arrives as a number, and the host annualizes it.

Connect

<script type="module">
  import { connect } from "./notebook.js";

  const nb = connect(); // wraps globalThis.notebook; pass a port to override

  nb.leaves();  // [{ symbol, cell, label, kind, columns, type }, ...] — the settable inputs
  nb.graph();   // { cell -> [upstream producer cells] } — the dependency edges
  nb.cells();   // every cell's metadata (the raw port objects, PascalCase — see "A note on casing")

  nb.start();          // run the first wave, so cells paint their defaults
  nb.set("c", 40);     // edit a leaf by its symbol; downstream recomputes
  nb.setMany({ c: 40, price: 3.5 }); // several leaves as ONE atomic edit
</script>

connect() throws if no port is found (the script ran before the WASM published globalThis.notebook, or you passed something that is not a notebook port).

loadNotebook — fetch, start, and connect in one call

connect() assumes the WASM is already running. loadNotebook does the whole boilerplate — instantiate the .wasm (with a buffered fallback for a host that doesn’t serve application/wasm), run it, and wait for the port — and resolves to a connected client. Crucially it has a timeout: a broken artifact or a build that never publishes the port rejects rather than polling forever.

<script src="wasm_exec.js"></script> <!-- defines globalThis.Go -->
<script type="module">
  import { loadNotebook } from "./notebook.js";

  const nb = await loadNotebook({ wasm: "./notebook.wasm", timeout: 10_000 });
  nb.subscribeEpoch(({ values }) => render(values));
  nb.start();
</script>

It owns the MIME fallback, go.run, the readiness poll, and the timeout — the error-prone parts a host would otherwise hand-roll (the manual sequence in The complete host page is what this replaces).

Feature detection

A notebook’s port grows over time (typed values, per-wave epochs, leaf types were all added after the first release). Rather than call a method and catch an exception to find out what an older .wasm supports, ask:

nb.capabilities();      // e.g. ["typed-events", "wave-settled", "epoch-events", "leaf-types", "atomic-set"]
nb.can("atomic-set");   // true — branch on this instead of try/catch

The list is derived from the port itself, never hand-maintained — so it can’t claim more than the port delivers. typed-events is present when subscribeValues is; leaf-types when at least one leaf carries its Go type; atomic-set when the port has setMany. The behavioral pair, wave-settled and epoch-events (below), can’t be probed from a static object, so they are reported together with typed-events — they shipped in the same release, so a port that has one has all three, and an older port truthfully reports none.

Atomic multi-leaf edit

setMany({ ... }) applies several leaves as one edit — a single epoch, one recompute wave — so a host changing related inputs together never lands an intermediate combination a subscriber could observe:

nb.setMany({ principal: 250000, rate: 0.065, term: 30 });

Prefer it over several set() calls for a form or a parameter-sweep step. It throws on a port that predates it; guard with nb.can("atomic-set"). (The HTTP equivalent is POST /set {"values": {…}}, which also returns the committed epoch in X-Notebook-Epoch — see ports.) There is deliberately no version number: a capability set that is always true of the port in hand beats a version a client has to map to features.

Typed values out — the point

subscribeValues delivers each cell’s value as a JavaScript value, not as the text it renders to. A scalar that a human sees as "40.24" arrives at a program as the number 40.24:

nb.subscribeValues((ev) => {
  // ev.cell is the cell id; ev.value is a real JS value (number, bool, object)
  if (ev.cell === "hourlyCost") {
    const annual = ev.value * 24 * 365; // arithmetic, not string concatenation
    console.log("annualized:", annual);
  }
});

An event is one of two shapes — a per-cell value, or the terminal wave-settled marker (see Coherent per-wave snapshots):

type ValueEvent =
  | { epoch: number; cell: string; value: unknown }  // one cell's typed value
  | { epoch: number; settled: true };                // the wave is complete

This is the capability the pipe adds. Before it, a host could only subscribe to rendered events{ mime, data }, where data is the string a human reads. Multiplying "40.24" by a number is a bug waiting to happen; multiplying 40.24 is arithmetic. Use subscribe when you want exactly what the UI shows (a chart’s SVG, a table’s HTML); use subscribeValues when a program needs the value.

// Rendered events — what a human reads (mime + data string):
nb.subscribe((ev) => { if (ev.mime === "image/svg+xml") draw(ev.data); });

// A synchronous snapshot of every leaf's current value, instead of subscribing:
nb.values();

subscribeValues throws on a notebook whose port predates it (an older .wasm). Catch it and fall back to subscribe if you must support both.

Coherent per-wave snapshots

Each value event carries the epoch of the wave it belongs to, and every wave ends with one terminal marker { epoch, settled: true } (no cell/value). So a host combining several derived values from one edit can tell that its set is from one wave, not a mix of an old and a new one:

nb.subscribeValues((ev) => {
  if (ev.settled) { /* every value for ev.epoch has now arrived */ }
  else            { /* ev.epoch, ev.cell, ev.value */ }
});

subscribeEpoch is the convenience for exactly this — it buffers a wave’s values and calls you once, when the wave settles, with { epoch, values }:

nb.subscribeEpoch(({ epoch, values }) => {
  // values.hourlyCost and values.carbon are from the SAME wave — safe to combine
  render(values.hourlyCost, values.carbon);
});

A superseded wave never settles (the engine only emits the marker for the wave that actually completed), so subscribeEpoch silently drops the abandoned buffer and only ever hands you a coherent set. It is built entirely on the one value stream; delete it and subscribeValues still works. (Value events key by cell idev.cell, values.hourlyCost — while set() and values() key by leaf symbol; see A note on casing.)

Each leaf carries its type

Every leaf reports its Go result type, so a program can validate a value’s shape before it sets it — without knowing Go. leaves()[i].type is { Name, Underlying }: the type as declared ("PerHour", "int") and the basic kind it resolves to ("float64", "int", "bool", "string"). Underlying is absent for a composite or interface leaf (a table row, a multi-selection), where no single scalar kind describes the value.

for (const leaf of nb.leaves()) {
  console.log(leaf.symbol, "→", leaf.type?.Name, `(${leaf.type?.Underlying})`);
  // e.g.  c → int (int)   lambda → PerHour (float64)
}

// A shape check the host can run itself, before set():
function okForLeaf(leaf, v) {
  switch (leaf.type?.Underlying) {
    case "int": return Number.isInteger(v);
    case "float64": case "float32": return typeof v === "number";
    case "bool": return typeof v === "boolean";
    case "string": return typeof v === "string";
    default: return true; // composite, or an older port with no type — defer to the coercer
  }
}

The type is readable, not compile-time enforced: set("c", "nope") is still not rejected by tsc — the port’s coercer rejects it at runtime, on the far side. You get the schema to check against, not a generated per-notebook type that fails your build. (A generated typed set() is a separately-gated feature; the shape you can read today covers the common case of validating input before sending it.)

One more shape worth knowing: values() reports leaf cells only — a derived cell like hourlyCost is absent there, so use subscribeValues to observe it.

A note on casing

There are two naming conventions in play, and it is worth being explicit about which you are touching. The raw port exposes Go wire fields in PascalCase; the client’s leaves() normalizes the top level to camelCase for ergonomics — but the nested type and columns objects are still the raw PascalCase shapes. The event streams have their own fixed lowercase schemas.

Surface Naming Example
Raw port — notebook.meta[i], cells()[i] PascalCase Go fields meta[0].Widget.Kind, meta[0].Type.Name, meta[0].In
Raw port — notebook.values() keyed by leaf symbol values().c
leaves()[i] (client-normalized) camelCase top level leaves()[0].symbol, .kind, .label
leaves()[i].type (nested, raw) PascalCase leaves()[0].type.Name, .type.Underlying
leaves()[i].columns[j] (nested, raw) PascalCase columns[0].Name, columns[0].Type
Rendered event — subscribe fixed lowercase { epoch, cell, state, mime, data, err }
Typed event — subscribeValues fixed lowercase { epoch, cell, value } or { epoch, settled: true }

Rule of thumb: if you read it off notebook. directly, it is PascalCase; if you read it off a leaves() row’s top level, it is camelCase; the nested type and columns are the raw PascalCase objects either way. When in doubt, cells() returns the port objects verbatim, so their keys are always the raw Go names.

The complete host page

The demo above is a full, deployable example — not a snippet. It is four files:

component/
├── index.html       your page: its own layout, its own controls
├── app.js           your logic: connect() → subscribe → start
├── notebook.js      the client (copied from client/notebook.js)
├── wasm_exec.js     Go's WASM support shim (copied from the toolchain)
└── notebook.wasm    the built notebook (notebook build --target=wasm)

index.html and app.js are yours; the other three are produced by the build. Get the runtime files this way — the .wasm name is content-addressed, so copy the one the build emits to a stable name your page fetches:

notebook build --target=wasm -o ./out examples/capacity
cp ./out/notebook-*.wasm component/notebook.wasm
cp ./out/wasm_exec.js     component/wasm_exec.js
cp client/notebook.js     component/notebook.js

The load-and-drive sequence, in app.js, has a specific order that matters:

import { connect } from "./notebook.js";

const go = new Go();
// 1. Load the WASM yourself — a host page owns its own page lifecycle.
const src = await WebAssembly.instantiateStreaming(fetch("notebook.wasm"), go.importObject)
  .catch(async () => WebAssembly.instantiate(  // fallback for hosts without the wasm MIME type
    await (await fetch("notebook.wasm")).arrayBuffer(), go.importObject));
go.run(src.instance); // publishes globalThis.notebook synchronously as it starts

// 2. Wait for the port to appear (go.run publishes it as it spins up).
const port = await new Promise((resolve) => {
  const t = setInterval(() => globalThis.notebook && (clearInterval(t), resolve(globalThis.notebook)), 5);
});

const nb = connect(port);

// 3. Subscribe BEFORE start(), so you don't miss the first wave's values.
let unsub;
try {
  unsub = nb.subscribeValues((ev) => { if (ev.cell === "hourlyCost") render(ev.value); });
} catch {
  status("this notebook predates subscribeValues — rebuild with a newer go-notebook");
}

// 4. Wire your own control to a leaf, then run the first wave.
myServersSlider.addEventListener("input", (e) => nb.set("c", Number(e.target.value)));
nb.start();

// 5. Clean up on navigation.
addEventListener("pagehide", () => unsub?.());

Deploy it like any static site — it is HTML + JS + a .wasm. Serve it over HTTP (WASM will not load from file://), set the application/wasm MIME type, and cache the content-addressed .wasm forever. See publish & deploy for the MIME type, caching, and a GitHub Pages / S3 recipe.

The full source of the working demo is in the repo: site/component/index.html and site/component/app.js — it draws its own two-panel layout, checks that the value arrived as a number (not the string "40.24"), and never touches the notebook’s built-in UI.

Verify it yourself

client/example.mjs drives the client end-to-end against a real built notebook, with zero dependencies:

notebook build --target=wasm -o /tmp/nb examples/capacity
cp client/notebook.js /tmp/nb/notebook-client.js
GOROOT=$(go env GOROOT)
PATH="$GOROOT/lib/wasm:$PATH" node client/example.mjs /tmp/nb

It enumerates the leaves, subscribes to typed values, runs the first wave, sets a leaf, and prints the recomputed typed values.