Learn/Website Service

Website Service

The Website service lets a Space publish the numbers a website quotes. Mark each value as a Reference, publish once, and every number on the page updates from one small manifest fetched in a single request.

Time19 min readLevelIntermediateUpdatedUpdated Sep 2026

01Why This Exists

Retyped Numbers Drift

A website that quotes a specification usually retypes it, and retyped numbers drift: the specification changes, the page does not, and nothing connects the two until somebody notices.

The Space already knew the right answer. It computed it. The Website service is the wire between the Space that owns a number and the page that displays it.

One Fetch, Not One Per Value

One constraint shapes the whole design: a page quoting twenty-five numbers must cost one request, not twenty-five and not one endpoint per value. So the publish bakes every referenced value into a single manifest, and the page fetches that one document.

Info
Why not serve the world?

The published world is every Space as compressed .echk chunks, and it exists to carry the world. Asking a browser to download it, decompress it and parse TOML to recover a few scalars is the wrong shape. The manifest is a second, small object written by the same publish, capped at 1 MB.

Status

New
Newly built

The service, all five Reference kinds, the bake at publish and the manifest routes are new. Studio publishes manifests today, and the witness at api.eustress.dev serves them with caching, pinning and rate limits. Access keys are half built: the witness checks a key when a publisher attaches one, but Studio does not mint or attach keys yet, so every manifest Studio publishes is open to any caller. Pin schema_version and keep the baked fallbacks described below in case a field name moves.

The consumer contract is deliberately generic. Nothing in it is specific to one site, one namespace or one product.

02The Website Service

A Folder of References

Website is a service, like Lighting or MaterialService: a folder at the root of a Space that holds one Reference per value your site displays. Every Space has one. New Spaces are scaffolded with it, and Studio adds it to an older Space the next time that Space opens, so it appears in the Explorer beside Workspace and the other services.

Space layout
Spaces/VCell/
  Website/
    _service.toml
    specific_energy/_instance.toml
    cycles_at_design_rate/_instance.toml
    part_count/_instance.toml
    can_length/_instance.toml

The Service File

Website/_service.toml (excerpt)
[service]
class_name = "Website"
icon = "website"

# The identifier the manifest publishes under, and the name a page writes
# in data-eus="{namespace}:{key}". Publish fails while it is empty.
namespace = "vcell"

# Bumped when the MEANING of a key changes, never when a value does.
# Consumers pin against it, so a rename is a change they opt into.
schema_version = 3

Select the Website service in the Explorer to edit both in Properties, where they appear under Publishing as Namespace and SchemaVersion. A new Space starts with an empty namespace and schema version 1. Keep every field a flat scalar under [service]: that is the shape Studio reads and writes back.

Advanced
A namespace belongs to the first account that publishes it

The namespace becomes part of a URL and a storage key, so use lowercase letters, digits and hyphens, start with a letter or digit, and keep it to 64 characters. The witness records the first account to publish a namespace as its owner, and a publish from any other account under that namespace fails with Namespace already claimed. Renaming a Space's namespace releases the old one.

What a Reference Is

A Reference is a named pointer from a manifest key to a value the Space already holds. It carries the label, unit, format and basis with it, so the consuming page never reinvents any of them. Each Reference is a folder under Website whose _instance.toml declares class_name = Reference and keeps its fields in [attributes]:

Website/specific_energy/_instance.toml
[metadata]
class_name = "Reference"
archivable = true

[attributes]
kind   = "instance"
source = "Workspace/V-Cell/V1/Core/Enclosure#material.custom.wh_per_kg"
label  = "Specific energy, pack level"
unit   = "Wh/kg"
format = "{:.0}"
basis  = "derived"

The folder name is the manifest key; set key to publish under a different name, and label defaults to the key. An agent can write these files with the website_setup and website_add_reference tools, which Workshop and the MCP server both offer. website_status lists what a publish would bake, and website_manifest_url prints the URL and markup for whoever maintains the site.

Tip
basis travels with every value

A figure without its basis is not a figure. instance, sim and expr References must declare one (measured, simulated, derived or authored), or the publish fails; count and measure carry counted and measured on their own. A site can then render a simulated number differently from a measured one while the list of which is which stays in the Space.

03The Five Reference Kinds

Why Five

The numbers a site quotes come from five different places. A page claiming 2,341 parts and 300 x 100 x 100 mm is reading the tree, not a scalar, and a design that only handled scalars would solve half the problem.

kindResolvesExample source
instanceA property on one instanceWorkspace/.../Enclosure#electrochemical.capacity_ah
simA value from a recorded experiment runbattery.capacity_retention
countInstances matching a path globWorkspace/V-Cell/V1/Assembly/**
measureA bounding-box measure over a subtreebbox:Workspace/V-Cell/V1/Assembly
exprArithmetic over other Referencesenergy_wh / pack_mass_kg

instance and sim

instance reads path#section.field, with the path relative to the Space root and dotted fields indexing into TOML tables. Resolution reads the live datamodel first: the instance, transform, material, thermodynamic, electrochemical, attributes and parameters sections come from the running Space, so a value the engine computed or reconciled is the one that gets baked. Other sections have no live form and are read from the stored instance text.

sim reads a recorded experiment run, never the live per-frame values, which carry no run identity. The publish looks in the Universe's .eustress/experiments folder for the newest run record whose name equals run_label, then reads the value source names:

A sim reference
[attributes]
kind      = "sim"
source    = "battery.capacity_retention"
run_label = "I_life_25C_5MPa_res0"
at        = "final"        # final | min | max | mean | at_cycle:N
basis     = "simulated"

final reads the run's final value; min, max and mean read its statistics; at_cycle:N reads the first telemetry sample where the counter named by cycle_key reaches N. A missing run_label fails the publish, and so does a label no run carries, with the nearest existing labels suggested. Runs come from experiments; see Simulation.

count, measure, expr

The derived kinds
# count: instances under a path glob
kind   = "count"
source = "Workspace/V-Cell/V1/Assembly/**"
filter = "class_name = Part"   # or class_name != Folder, or tag = <name>

# measure: a bounding box over a subtree, converted from meters
kind   = "measure"
source = "bbox:Workspace/V-Cell/V1/Assembly"
axis   = "x"                   # x | y | z | volume | surface
unit   = "mm"

# expr: arithmetic over other reference keys in this namespace
kind   = "expr"
source = "energy_wh / pack_mass_kg"
basis  = "derived"
  • count: ** matches one or more path segments, so Assembly/** counts what is under Assembly and never Assembly itself. * stays inside one segment and ? matches one character. A count of zero fails the publish, since it almost always means a wrong path.
  • measure: only bbox: is computed. It walks every part under the path, the same walk the selection box uses, so the datasheet and the viewport agree by construction. axis defaults to x, and unit is a length (m, cm, mm, ft or in) converted from meters. For volume and surface the number is in that unit cubed or squared, so put the cube or square in format, as in {:.2} m³. The prefixes hull:, mesh: and convex: are reserved and fail as not implemented.
  • expr: operands are other reference keys, and their units ride along to the manifest without entering the arithmetic. Each expression runs after everything it names, a cycle fails and names the loop, and division by zero or a non-finite result fails too.

04Publish and Bake

Where the Bake Runs

The bake runs inside Publish, in two halves. Before anything uploads, Studio resolves every Reference against the live Space, and a failure stops the publish there, before a listing exists. After the world uploads, Studio stamps the manifest with the simulation id and the world's hash and sends it to the witness. If that upload fails, the publish fails too.

Publish
resolve every Reference, in dependency order
   |
   +-- any failure  ->  publish stops, no listing created
   |
find or create the listing, upload the changed chunks, commit the world
stamp simulation_id and publish_hash
PUT the manifest  ->  a failed upload fails the publish
Advanced
An unchanged Universe does not republish

Studio skips the upload, manifest included, when neither the world nor the listing text changed since the last publish. References read live values, but the world is what the Space saved, so save the change that moved a number before you publish.

A Failed Reference Fails the Publish

Advanced
No null, no previous value

The bake emits no null and carries no previous value forward. The failure this feature exists to prevent is a website confidently displaying a stale number, and a bake that degraded quietly would bring that failure back one layer further from anyone who would catch it.

The error names the reference, the source it could not resolve and the nearest candidates in the tree:

Studio notification
Publish stopped: specific_energy: no instance at Workspace/V-Cell/V1/Core/Enclosur (did you mean Workspace/V-Cell/V1/Core/Enclosure?)

That is a fix, where resolution error would be a ticket. The same rule covers every other failure: a missing basis or run label, two References claiming one key, an unknown operand, a format the engine cannot apply, a count of zero, and a value with no JSON form, such as infinity.

Formatting display

format turns the typed value into the display string. The grammar is small and explicit, and a spec outside it fails the publish instead of rounding some other way:

format2341.5 renders as
none, or {}2341.5
{:.0}2342
{:.2}2341.50
{:,}2,341.5
{:,.0}2,342
{:e}2.3415e3

{:.Ne} sets the decimals in scientific notation. Text around the placeholder is kept, so {:.0} Wh/kg renders 953 Wh/kg for 952.6, and {{ and }} write literal braces. A whole number prints without a decimal point: 237, not 237.0.

Where the Manifest Lands

The manifest is a separate object from the simulation listing. The listing carries the name, description and thumbnail the gallery shows; the manifest carries values. They sit side by side under the same Universe prefix and are written by different requests, so a manifest upload never touches the listing.

Storage layout
universes/{id}/chunks/{hash}.echk        the world's chunks
universes/{id}/manifests/{sha256}.json   the world manifest each publish commits
universes/{id}/website-manifest.json     the website manifest for that publish
thumbnails/{id}/thumb.webp               listing thumbnail
Info
One worker, one bucket

Manifests are served by the eustress-api worker at api.eustress.dev out of the eustress-simulations bucket, under the universes/ prefix that publish already writes. The worker also keeps a small index from each namespace to its latest publish, which is how the namespace route finds the newest manifest.

05Consuming the Manifest

The Endpoint

HTTP
GET https://api.eustress.dev/api/simulation/{namespace}/latest/manifest
GET https://api.eustress.dev/api/simulation/{simulation_id}/manifest

Use the namespace route. Every publish creates a new listing with a new simulation id, so a URL holding an id is right until the next publish, while the namespace route always serves the latest. The id route is for pinning one publish. The segment counts as an id when it is shaped like a UUID and as a namespace otherwise, and the form without latest works too.

The route answers any origin with Access-Control-Allow-Origin: *, and answers CORS preflights the same way, cached for a day.

What Comes Back

website-manifest.json
{
  "namespace": "vcell",
  "schema_version": 3,
  "simulation_id": "8f3a1c04-1111-2222-3333-444444444444",
  "publish_hash": "4f9c2b7e...",
  "baked_at": "2026-09-06T07:41:00Z",
  "engine_version": "0.3.6",
  "values": {
    "cycles_at_design_rate": {
      "value": 237,
      "unit": "cycles",
      "label": "Cycles to 80% retention",
      "basis": "simulated",
      "display": "237",
      "source": "battery.capacity_retention",
      "run_label": "I_life_25C_5MPa_res0"
    },
    "specific_energy": {
      "value": 952.6,
      "unit": "Wh/kg",
      "label": "Specific energy, pack level",
      "basis": "derived",
      "format": "{:.0}",
      "display": "953",
      "source": "Workspace/V-Cell/V1/Core/Enclosure#material.custom.wh_per_kg"
    }
  }
}

Values are keyed by reference name and sorted. publish_hash is the BLAKE3 digest of the published world's manifest as 64 hex characters, and it doubles as the ETag. value is a number, string or boolean, never null; unit and format appear when the Reference sets them, and run_label only on sim values.

Info
Render display. Compute with value.

Every entry carries both. value is typed, for arithmetic. display is formatted by the author, for the DOM. A consumer that formats value itself drifts from the author's rounding, and a consumer that parses display back into a number gets it wrong the first time a unit appears in it.

Binding with data-eus

Mark up what each element means. Fetch once, for all of them.

index.html
<span data-eus="vcell:specific_energy">953</span>
<span data-eus="vcell:specific_energy" data-eus-field="unit">Wh/kg</span>

<span data-eus="vcell:cycles_at_design_rate">237</span>
<span data-eus="vcell:cycles_at_design_rate" data-eus-field="basis">simulated</span>

data-eus is the namespace and the key, joined by a colon. data-eus-field picks which field of the entry to write, and defaults to display.

Important
The element's existing text is the fallback, and it must already be correct

Bake the current values into the HTML at build time and let the manifest correct them. The page is already right before the fetch, and the fetch can only improve it. A page that renders empty until a fetch resolves renders empty when the fetch fails, and a numeric specification that flashes blank reads as broken to exactly the audience it is meant to convince.

The Hydration Script

hydrate.js
// One fetch for every number on the page. The HTML is already correct; this
// only corrects it for publishes that landed after the last deploy.
const NAMESPACE = 'vcell';
const SCHEMA = 3;      // the schema_version this page was written against
const KEY = '';        // only if the publisher uses a key; public by design
const MANIFEST =
  `https://api.eustress.dev/api/simulation/${NAMESPACE}/latest/manifest`;

async function hydrate() {
  let m;
  try {
    const res = await fetch(MANIFEST, {
      headers: KEY ? { 'X-Eustress-Key': KEY } : {},
      cache: 'default',                  // let the browser revalidate
    });
    if (!res.ok) return;                 // 401, 404, 429, 5xx: keep the baked values
    m = await res.json();
  } catch { return; }                    // offline: keep the baked values

  if (m.schema_version !== SCHEMA) {     // a rename is opt-in, never automatic
    console.warn('eustress: schema', m.schema_version, 'expected', SCHEMA);
    return;
  }

  for (const el of document.querySelectorAll('[data-eus]')) {
    const [ns, key] = el.dataset.eus.split(':');
    if (ns !== m.namespace) continue;
    const v = m.values[key];
    if (!v) { console.warn('eustress: no manifest key', key); continue; }
    const field = el.dataset.eusField || 'display';
    if (v[field] !== undefined) el.textContent = v[field];
  }
  document.documentElement.dataset.eusHash = m.publish_hash;
}
hydrate();

Twenty-five values cost one request, and a twenty-sixth costs nothing. Every early return leaves the baked values standing, which is the whole failure policy in a few lines. A request without the key header is a simple CORS request, so an open manifest costs no preflight at all.

06Caching, Keys, Failure

Response Headers

HeaderValueWhy
ETagThe publish hash, in quotesThe engine already computes it for every publish
Cache-Controlpublic, max-age=300, stale-while-revalidate=86400A repeat visitor costs nothing, and a stale copy renders while a fresh one arrives
VaryX-Eustress-KeyA shared cache never hands a keyed response to a caller without the key
Access-Control-Allow-Origin*Manifests are published content; a key, not the origin, identifies a caller
Access-Control-Expose-HeadersETagCross-origin JavaScript, such as a build step, can read the hash

Four consequences follow:

  • A repeat visit inside five minutes makes no network request.
  • After five minutes the browser serves the cached copy and revalidates behind it, so the visitor waits for nothing.
  • An unchanged manifest answers 304 with no body.
  • A publish changes the hash, so the next revalidation returns 200.

Error responses carry Cache-Control: no-store, so a rejection never sticks in a cache. Reads are limited to 120 a minute per key, or per namespace for an open manifest, counted per Cloudflare location.

Advanced
Skip the cache-busting query string

Appending a timestamp on every load defeats revalidation, turns a free 304 into a full download on every visit and spends the rate limit on nothing. Publishing is the refresh. There is one mechanism, because a second mechanism is a second thing to forget.

Pinning a State

A page that must show one exact state pins it, passing publish_hash exactly as the manifest carries it. A pinned response is immutable and cacheable for a year.

Pinned request
GET https://api.eustress.dev/api/simulation/{simulation_id}/manifest?v={publish_hash}

Pin through the simulation id. The namespace route moves to each new publish, so a namespace pin answers 404 with pinned_hash_not_available as soon as you publish again, rather than serving a different state. The id route keeps serving the publish it names. Use it for anything that quotes a specific revision: a signed document, a datasheet, a figure a reader may come back to.

Access Keys

A manifest is open unless its publisher attached a key to the upload. Studio does not attach one yet, so manifests published from Studio today need no key. The witness side of keys is already in place:

  • Sending: consumers send the key in the X-Eustress-Key header, or as ?key= where a header is impossible. A key in a URL lands in access logs, Referer headers and browser history, so prefer the header.
  • Checking: the witness stores only a SHA-256 hash of the key. A missing key gets 401 auth_key_required and a wrong one 401 auth_key_invalid.
  • Rotating: when a key changes, the previous one keeps working for 30 days by default, so a live site can redeploy on its own schedule. A window of 0 cuts the old key off at once.

The Access rows in the Website service's Properties (KeyId, RotateKey and the rest) are not wired to the witness yet: turning RotateKey on mints no key.

Important
The key is attribution, not secrecy

A key that a public website sends from browser JavaScript is visible to anyone who opens devtools, reads the page source or watches the network tab. It tells the author who is calling, lets a rate limit apply per caller and can be cut off by rotation. It leaves the manifest readable by anyone who copies the key out of the page. Real confidentiality needs a server-side proxy holding a secret, or short-lived signed tokens, and this is neither. Publish only values you are willing to have read.

The Failure Table

ConditionBehavior
Network unreachableKeep the baked values, no visible change
401: key missing, wrong, or past its overlap windowKeep the baked values, log
404: unknown namespace, no manifest yet, or a pin that no longer matchesKeep the baked values, log
429: over the rate limit, with Retry-After: 60Keep the baked values, back off
Any other errorKeep the baked values, log
schema_version mismatchKeep the baked values, log, apply nothing at all
Key missing from the manifestLeave that element alone, log the key
A null valueCannot happen: a failed reference fails the publish

One rule covers every row: the page is already correct before the fetch, and the fetch can only improve it. Anything else turns a network problem into a credibility problem.

07Consumer Practice

Build-Time Baking

The same manifest feeds the build. That is what keeps the fallbacks honest.

Pre-deploy step
fetch manifest  ->  write values into the HTML  ->  deploy

Run it as a pre-deploy step, and fail the build when a data-eus key has no manifest entry. That catches a renamed reference at build time instead of on a visitor's screen, and it means the committed HTML always shows the state of the last publish. With build-time baking in place, runtime hydration becomes a correction for publishes that landed after the last deploy: the build keeps the page correct, and the fetch keeps it current.

Four Honest Limits

No push

It Does Not Push

A site learns about a publish on its next fetch. With a five-minute max-age, that is the update latency.

Scalars

It Carries Scalars

Each value is one number, string or boolean, in a manifest of at most 1 MB.

Public

It Does Not Make Values Private

Manifests are open today, and a key would name callers, not hide values.

One state

It Follows the Latest Publish

The namespace route shows the newest state. An older one is reachable only by its simulation id.

Checklist for a New Consumer

  1. Pick the namespace and pin schema_version.
  2. If the publisher uses a key, get it from them. Manifests Studio publishes today are open.
  3. Mark values with data-eus, carrying the current correct value as the element's text.
  4. Add the hydration script once, at the end of the document.
  5. Add the build-time bake, and make a missing key fail the build.
  6. Load the page with the network disabled and read every number.
Tip
Step 6 is the one that matters

If the page is right with the network off, the manifest is an improvement. If it is wrong with the network off, the manifest is a dependency, and you have moved your credibility onto someone else's uptime.

08What's Next

Keys from Studio

Turning on RotateKey will mint a key, show it once and never store it in the Space, because Website/_service.toml ships inside the published world. The next publish will attach it, and the previous key will keep working for KeyOverlapDays. Keys will come in two kinds, told apart by prefix: eus_pk_ for a browser key that ships in a page, and eus_bk_ for a build key held as a CI secret, each revocable on its own.

Faster Authoring

Add to Website in the Explorer's context menu will create a Reference pre-filled with the selected instance's path, and the Website service will show each Reference's current resolved value, so a wrong path shows up before publishing rather than after. Measures beyond bounding boxes will each arrive under their own prefix, so no existing Reference changes meaning.

Publish once. Every number on the page follows.

Loading Eustress Engine...