Ports and wire formats
The exact contract for driving a notebook from outside its own UI — over HTTP (the served binary) or over the in-page JS object (the WASM build). Everything a durable driver needs: request bodies, response schemas, the event shape, and what is and isn’t guaranteed.
A notebook exposes one port in two directions: you set a leaf (data in) and you subscribe to cells (data out). Every transport is a projection of that single contract — the same two operations whether the counterparty is a human at a slider, a feed driver, or another program. See live feeds for the pattern this enables and the JS client for a typed wrapper over the browser side.
HTTP (the served binary: notebook run, or a built binary served)
The server binds 127.0.0.1:8080 by default (--addr to change it) and serves
four endpoints. It is an unauthenticated local endpoint — see
Security before exposing it.
POST /set — edit a leaf
Request body:
{"leaf": "c", "value": 40}
-
leafis the leaf’s result name (the symbol it produces), not its function name — the same name--setand the dependency graph use. -
valueis any JSON value; it is coerced to the leaf’s Go type by the same coercer a browser edit crosses. A scalar, a bool, a string, an array (a multi-select or draggable selection), or an object (a structured/handle leaf) are all accepted; numeric values keep int-vs-float viajson.Number. -
The edit is validated synchronously; the recompute runs asynchronously. The server coerces the value to the leaf’s type before returning, then runs the wave in the background — results arrive over
/events. The status tells a driver whether the edit was accepted:204 No Content— accepted; the wave is running.404 Not Found— no leaf by that name (a typo’dleaf, or the result symbol of a derived cell rather than an input leaf).422 Unprocessable Entity— the leaf exists but the value will not coerce to its Go type (e.g. a string for a numeric leaf).400 Bad Request— the body itself is malformed JSON.
So a mistaken edit fails loud instead of looking successful: a
404/422means nothing changed, and you know it at the POST rather than discovering it by the cell that never updated. Validation does not wait for the wave — only coercion, which is synchronous.
Atomic multi-leaf edit
To change several leaves together, post a values map instead of a single
leaf/value:
{"values": {"principal": 250000, "rate": 0.065, "term": 30}}
- All or nothing. Every value is coerced first; if any leaf is unknown
(
404) or a value won’t coerce (422), the whole edit is rejected and nothing is written — no partial application. - One epoch, one wave. The accepted values enter under a single epoch and drive one recompute, so a subscriber never observes an intermediate combination (three sliders moved together, not three separate waves). This is what makes the port suitable for a form submit or a parameter-sweep step.
- The response reports the epoch. On
204, anX-Notebook-Epochheader carries the committed epoch, so a driver can correlate the edit with thesettledmarker it will see on/events(below). Unlike the single form, a batch edit runs its wave before returning — it is a deliberate one-shot, not a fire-and-forget drag.
GET /events — subscribe to cell updates (SSE)
A text/event-stream. On connect, the server replays the current committed
state to the new client only — the latest event for each cell, so a freshly
opened page paints immediately without a blank frame. It does not run a fresh
wave to do this, so a second tab connecting neither re-runs the notebook’s cells
nor pushes a redundant repaint to clients already connected. (Only the very first
connection, before any wave has run, triggers the initial wave to produce that
state.) Each event is one line:
data: {"epoch":7,"cell":"hourlyCost","state":"done","mime":"text/plain","data":"40.24"}
The JSON payload is the frozen wire-event shape:
| Field | Type | Meaning |
|---|---|---|
epoch |
number | the wave this event belongs to; monotonic, bumped per edit |
cell |
string | the cell id (empty on the wave-settled marker — see below) |
state |
string | running | done | error | blocked | stale | settled |
mime |
string | present on done with rendered output (image/svg+xml, text/html, text/markdown, text/plain) |
data |
string | the rendered payload for that MIME; omitted when empty |
err |
string | present only on state: "error" |
mime/data/err are omitted when empty (omitempty), so a bare transition
carries only epoch/cell/state.
The wave-settled marker. When a wave has run every cell to completion without
being superseded by a newer edit, the stream emits one terminal event with an
empty cell:
data: {"epoch":7,"cell":"","state":"settled"}
This tells a program the wave is coherent and complete — every cell that was going
to update in epoch 7 has. A superseded wave never emits it (the newer wave will),
so a consumer buffering by epoch can flush its set on settled and know it holds
one wave’s values, never a mix. The default UI ignores it (it keys on a cell that
does not exist). This is the SSE parallel of the JS client’s
subscribeEpoch.
GET /leaves — current leaf values
Returns a JSON object of leaf cells only (inputs), keyed by result name, for seeding controls:
{"c": 80, "price": 1.006, "target": 0.2}
Derived cells (e.g. hourlyCost) are not here — observe those on /events.
If no wave has run yet, one is run so defaults exist.
GET / — the built-in UI
The HTML page that mounts the default client. A host driving the notebook
programmatically ignores this and uses /set + /events.
Guarantees and non-guarantees
- Epochs supersede. A newer edit bumps the epoch and cancels older in-flight waves; a client should treat the highest epoch seen for a cell as current and drop stale lower-epoch events. Rapid edits (a slider drag) coalesce in the scheduler.
- No missed-event replay. The SSE stream carries no
id:/retry:fields, so there is noLast-Event-IDresumption. On reconnect the server replays the current committed state (the latest event per cell) to that client — you get the current truth, not the events you missed. A driver should be state-based (react to the latest value per cell), not event-log-based. - Ordering within a wave is per-cell; across waves, the epoch orders them.
- Slow consumers are dropped, not backpressured. Each subscriber has a
256-event buffer; if a client can’t keep up and the buffer fills, further events
are dropped for that client (the wave never blocks on a slow reader). Because
the contract is state-based, a client that missed intermediate events still
converges on the correct current value once it drains — and a reconnect replays
current state. There is no per-client flow control or configurable queue bound
today; a client that needs guaranteed delivery of every transition should poll
/leaves(state) rather than rely on the event log.
Browser (the WASM build: globalThis.notebook)
A WASM notebook publishes one plain-data object, globalThis.notebook, with the
same one-port contract. All values cross as plain JS (numbers, bools, arrays,
objects) — JS never sees a Go type.
notebook.meta // CellMeta[] — the graph, labels, leaf symbols, widget kinds, leaf types
notebook.provenance // build identity (source hash, commit)
notebook.set(leaf, value) // edit a leaf (data in) — same coercer as POST /set
notebook.subscribe(fn) // rendered events {epoch,cell,state,mime,data,err} → returns unsubscribe
notebook.subscribeValues(fn) // TYPED value events {epoch,cell,value} + {epoch,settled} → returns unsubscribe
notebook.values() // synchronous snapshot of every leaf's current value
notebook.start() // run the first wave, so cells paint their defaults
subscribe(fn)delivers the same wire-event shape as/events(mime/data strings — what a human reads).subscribereturns an unsubscribe function.subscribeValues(fn)delivers typed values — a per-cell event{epoch, cell, value}(wherevalueis a real JS number/bool/object), and once per settled wave a terminal marker{epoch, settled: true}(no cell/value). What a program computes on. See the JS client, which wraps this with types and a coherent-snapshot helper.- The startup order matters: the port is published during WASM init; wait for
globalThis.notebookto exist, install your subscriber, then callstart().
Security
The built-in server is a local development and trusted-network endpoint, not a multi-user service:
- Authentication is opt-in. By default any client that can reach the port may
read
/eventsand mutate any leaf via/set. Pass--tokento require one:--token=autogenerates a random token (printed in the readiness line and the startup log), or--token=<value>uses a value you supply. Every request must then carry it — anX-Notebook-Tokenheader or a?token=query param — or gets401. This is the cross-user defense the Host check does not provide: on a shared host any local process sends a loopbackHost, but only one that read the readiness line knows the token. The token is compared in constant time. (WASM builds serve no HTTP, so this applies to the served native binary.) - No TLS and no per-request rate limit beyond Go’s defaults.
POST /setdoes cap its body (1 MiB →413); other endpoints do not. - No CORS headers. The server emits no
Access-Control-Allow-Origin, so browsers apply the same-origin policy by default — a page from another origin cannot read/eventsor/setresponses. (This is the opposite of a permissiveAccess-Control-Allow-Origin: *; the server neither loosens nor tightens CORS, it stays silent and lets the browser’s default stand.) - DNS-rebinding defense (Host check). The same-origin policy does not stop a
cross-origin page from sending a
POST /set— and a page atevil.comcan rebind its name to127.0.0.1to reach a localhost server. So when it is bound to loopback (the default), the server requires a loopbackHostheader (127.0.0.1,localhost,[::1]) and answers403otherwise. A browser tricked into resolvingevil.comto127.0.0.1still sendsHost: evil.com, which is refused; a real local client, or anssh -Ltunnel (which targets127.0.0.1on the notebook’s box), sends a loopback Host and is allowed. A server you deliberately bind to a non-loopback address (--addr 0.0.0.0) has opted into exposure — the Host check is off there and belongs to your proxy. - It binds
127.0.0.1by default, so it is not exposed off the machine unless you change--addror put it behind something.
Before any network exposure, place authentication and TLS in front of it (a reverse proxy — nginx, Caddy — terminating TLS and enforcing auth), and treat a served notebook as a trusted local service. A notebook you did not write should be treated like any other untrusted program: it is compiled Go with full host access in the served/native topologies (the WASM topology is sandboxed by the browser). See also Rendering is trusted code.