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.
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.
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
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.
Spaces/VCell/
Website/
_service.toml
specific_energy/_instance.toml
cycles_at_design_rate/_instance.toml
part_count/_instance.toml
can_length/_instance.tomlThe Service File
[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 = 3Select 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.
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]:
[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.
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.
| kind | Resolves | Example source |
|---|---|---|
instance | A property on one instance | Workspace/.../Enclosure#electrochemical.capacity_ah |
sim | A value from a recorded experiment run | battery.capacity_retention |
count | Instances matching a path glob | Workspace/V-Cell/V1/Assembly/** |
measure | A bounding-box measure over a subtree | bbox:Workspace/V-Cell/V1/Assembly |
expr | Arithmetic over other References | energy_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:
[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
# 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, soAssembly/**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.axisdefaults to x, andunitis 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 informat, as in{:.2} m³. The prefixeshull:,mesh:andconvex: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.
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 publishStudio 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
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:
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:
format | 2341.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.
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 thumbnailManifests 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
GET https://api.eustress.dev/api/simulation/{namespace}/latest/manifest
GET https://api.eustress.dev/api/simulation/{simulation_id}/manifestUse 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
{
"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.
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.
<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.
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
// 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
| Header | Value | Why |
|---|---|---|
ETag | The publish hash, in quotes | The engine already computes it for every publish |
Cache-Control | public, max-age=300, stale-while-revalidate=86400 | A repeat visitor costs nothing, and a stale copy renders while a fresh one arrives |
Vary | X-Eustress-Key | A 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-Headers | ETag | Cross-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
304with 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.
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.
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-Keyheader, 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_requiredand a wrong one401 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.
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
| Condition | Behavior |
|---|---|
| Network unreachable | Keep the baked values, no visible change |
401: key missing, wrong, or past its overlap window | Keep the baked values, log |
404: unknown namespace, no manifest yet, or a pin that no longer matches | Keep the baked values, log |
429: over the rate limit, with Retry-After: 60 | Keep the baked values, back off |
| Any other error | Keep the baked values, log |
schema_version mismatch | Keep the baked values, log, apply nothing at all |
| Key missing from the manifest | Leave that element alone, log the key |
A null value | Cannot 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.
fetch manifest -> write values into the HTML -> deployRun 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
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.
It Carries Scalars
Each value is one number, string or boolean, in a manifest of at most 1 MB.
It Does Not Make Values Private
Manifests are open today, and a key would name callers, not hide values.
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
- Pick the namespace and pin
schema_version. - If the publisher uses a key, get it from them. Manifests Studio publishes today are open.
- Mark values with
data-eus, carrying the current correct value as the element's text. - Add the hydration script once, at the end of the document.
- Add the build-time bake, and make a missing key fail the build.
- Load the page with the network disabled and read every number.
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.