A guide for client teams building a Helix client extension. You work in your own client repo against the
published @helix/client-sdk and the helixCore container image — you do not need the helixCore source. The
container (helixctl) is your dev loop, build tool, and deploy tool.
@helix/client-sdk — the typed extension contract (npm)
client icons, logo, templates, helix overrides
helixctl — the launcher (baked into the image)
The platform is client-agnostic: it loads your extension at runtime from a mounted /client bundle and your
domain/map config from /app/<CLIENT>. You never edit helixCore — everything client-specific lives in your repo.
A client need not ship an extension at all. A domain-file-only client’s bundle carries helix-config/
and a client/manifest.json but no clientConfig.js; the image then serves its own stock client — no
overrides, the core’s default entity templates, and your manifest’s company and version in About. Everything
such a client can express is in its domain file, so every helix value must be a literal: an expression like
"getNewProjectColor()" has nothing to resolve to and falls through as a string. Adding a clientConfig.tsx
is the step from domain-file-only to a client extension; nothing else about the bundle changes. (A client/
that carries files but no clientConfig.js is treated as a broken build and refused, never quietly replaced.)
The same holds for a demo of such a client’s tree: when the repo has no extension source (no
helix/custom/package.json), the demo’s own copy gets the stock client. A repo that has extension source
and no built bundle is unbuilt, and demo still refuses it and says to build it.
package.json # depends on @helix/client-sdk; `typecheck` + `build:client` scripts
tsconfig.json # typecheck config — mirrors how Vite builds (see §8)
.npmrc # points @helix/* at the GitLab npm registry (anonymous pull, no token)
vite.client.config.ts # builds the extension -> ../runtime/client
frontend/clientConfig.tsx # THE extension entry: ClientConfig (templates, overrides, logo, editors)
crudClientModule.cjs # OPTIONAL backend (crud) hooks: check / onCreate / onComplete / stop matrix (.cjs so it stays CommonJS despite the package's "type":"module")
runtime/ # launcher + transient run artifacts
helixctl # the BOOTSTRAP stub — fetches + runs the real launcher from the image
VERSION # the image tag helixctl pulls — the floating SDK line, e.g. `sdk-0.1`
SDK_VERSION # human-readable note of the SDK built against (image is authoritative)
CLIENT_REGISTRY # OPTIONAL — where `release --as image` publishes this client's images
client/ # the built bundle (clientConfig.js [+ icons]) — served as /client (git-ignored)
client.tar # the sealed bundle the container mounts (git-ignored)
helix-config/ # the client's domain/map/values, as deployed (git-ignored)
manifest.json # how THIS machine was installed (git-ignored)
.helixctl-cache/ # the extracted real launcher, keyed by image id (git-ignored)
storage/ # EVERYTHING this node holds — git-ignored by ONE rule
data/ # the database
mongo/ # mongod's own log
logs/ # crud.log, 2crud.log, … rotated at each node start
helix.env # this node's environment + secrets (mode 0600)
bare/session-<pid>/ # one bare-metal session, named for its process; swept when its pid goes
scenarios/ # Helix-only client scenarios (mounted in dev via the helix/ mount)
tests/ # client tests + results — at the ROOT, shared with the optimiser-only world
Built bundles do not land in the repo.helixctl release writes to $HELIX_RELEASE_DIR, default
~/Honeywick/release — the development root, because a bundle is something this machine produced rather
than something a node holds. A node’s own directory is the other thing entirely; see
Storage and logs.
A client that predates Helix may have a release/ of its own at the repo root, holding the optimiser
binaries and their licences, because its host ran the optimiser directly as a drop-in. That is legacy and
specific to such a client: the core image carries the optimiser, so a new client repo never needs one and
should not have one.
Tracked versus held. What a person decided is tracked: the extension source, the scenarios, and the pin
(VERSION, SDK_VERSION, CLIENT_REGISTRY). What a machine produced or holds is not — the built bundle, the
sealed tar, the manifest, and everything under storage/.
That last one is a single ignore rule rather than a list of artefacts, and the reason is a scar. The list
used to name them one at a time, and when helix.env arrived nobody added it — so a file of live secrets was
committed to a client’s own repository. One directory cannot be escaped by the next thing somebody adds. See
Storage and logs for the whole layout and for the three devices you
can put the heavy parts on.
Your helix/runtime/helixctl is a bootstrap stub, not the launcher. The real, full-featured helixctl
lives inside the helixCore image (/app/helixctl) and is versioned with the core. The stub only acquires the
image (login/pull/load), then extracts that real launcher (caching it in .helixctl-cache/) and runs it
with HELIX_HOME pointed back here. So you never hold or re-sync the full launcher — it always matches the
image you pulled. The stub itself almost never changes; bump it only if Honeywick ships a new bootstrap.
Only the stub + pins (helix/runtime/{helixctl,VERSION,SDK_VERSION}) are tracked; the built client/ bundle,
logs/, and .helixctl-cache/ are git-ignored. The image lives in your runtime’s local store
(podman’s or Docker’s) — helixctl pull fetches it.
On PATH: podman or docker — either runs the whole loop. You do not need node or npm on the
host — the container installs your extension’s deps and builds the bundle itself. With both installed
podman wins; HELIX_CONTAINER_RUNTIME=docker (or =podman) chooses. helixctl check reports which one
it resolved.
A host Linux kernel of 4.18 or newer — the container runs on it, and the image’s node needs 4.18.
helixctl check reports it beside the runtime.
Registry access — log in to the container registry to pull the image (see helixctl login below). The
@helix/client-sdk npm registry is anonymously pullable, so installing the SDK needs no token (the
repo’s .npmrc scopes @helix/* to it; do not add an _authToken line).
SPIRAL_LICENCE in your env — a Spiral command, e.g. {"set":{"licence":"<hex>"}} (not the raw key;
Spiral reads this env var). Optional: GOOGLE_MAPS_API_KEY, AA_VEHICLE_API_KEY.
No host install step: on the first helixctl <CLIENT> start dev, the container runs npm ci/npm install for you
(into a cached node_modules volume) before starting the watch build.
The repo ships the launcher at helix/runtime/helixctl, pinned to a compatible image via helix/runtime/VERSION. That
file holds the floating SDK tag for the SDK line you build against — e.g. sdk-0.1 — not an exact core
version. helixctl pull then always fetches the latest core that ships SDK 0.1.x, so core bug-fixes and
improvements arrive with a plain pull and no re-pin (the SDK contract is what stays fixed). From the
repo root:
./helix/runtime/helixctlpull# pull the latest compatible core (tag from helix/runtime/VERSION)
If helix/runtime/helixctl is absent, re-fetch the bootstrap stub from the image (note: helixctl-bootstrap, not
the full helixctl — the stub is what you commit; it pulls the real launcher itself):
You only change helix/runtime/VERSION when you adopt a new SDK line (a breaking contract bump) — bump it to the
new tag, e.g. sdk-0.2, alongside the @helix/client-sdk dependency.
Run from the repo root (the dir that holds helix/ and tests/):
Terminal window
./helix/runtime/helixctl<CLIENT>startdev# e.g. ./helix/runtime/helixctl AA start dev
This runs the helixCore container as a single node, mounts helix/ at /client-src (so config comes from
custom/, the served bundle is runtime/client/, and scenarios/ rides along), additionally mounts the root
tests/ (shared with the Spiral-only world), and runs vite build --watch inside it. On the first run the
container installs your extension’s deps (npm ci/npm install) into a cached node_modules volume — so the
host needs no node/npm, just a container runtime. It prints a URL — the host port it picked, which is the one to
open (the app:3000 in the start-up line is the port inside the container). An open page follows the node it came from: after a
sign-in, and whenever you return to the tab, it checks that the node behind its address is still the one it loaded
from, and reloads itself if another has taken the port or the core has changed. The helix/ dir is found
automatically as the parent of helix/runtime/; pass an explicit path only for a non-standard layout
(… dev /path/to/helix).
No repo, no start.dev, tuning and demo run on your repo, so they refuse rather than start empty: the
launcher refuses a directory with no custom/<CLIENT>.domain.json, and the container refuses when what it was given
differs from what the launcher saw — which is how a Docker daemon that cannot see the repo’s path shows itself (it
mounts an empty directory there instead of failing; a rootless daemon can miss a filesystem mounted after it
started). Both say what each side held and how to check. A start that gets the repo says so in one line:
A container reads no host file. Everything host-specific — a licence, a maps key, a vehicle-API token — reaches
the node as an environment variable that helixctl forwards by name: the core’s own set, every
{ "env": "NAME" } your values.json declares (in dev, tuning and demo it is your repo’s values.json
that counts), and any optional name you list in HELIX_SECRET_ENVS. Export the values in the launching
account’s environment (~/.bashrc). The old config.local overlay, which a client’s config sourced inside the
container, is retired (0.0.18.5b): a node that still finds one refuses to start and names its keys, so move each
one — a value the node needs into values.json, an optional one into HELIX_SECRET_ENVS — and delete the file.
On Docker: the dev loop writes the built bundle into your working tree (helix/runtime/client). Under a
rootful Docker daemon — the usual Linux install — container-root is host-root, so helixctl passes your
uid/gid in and the build runs as you: the files stay yours, exactly as under rootless podman. Rootless
Docker and Docker Desktop already map ownership, and need nothing. Nothing to configure either way.
The loop:
Edit clientConfig.tsx, a component, or <CLIENT>.domain.json.
Vite rebuilds the bundle in ~1s (watch clientbuild.log — see Logs below; the first run also logs the
one-time npm install there).
Refresh the browser to load the new extension. (Build-and-refresh — there is no hot module replacement.)
Your domain/map config is read live from the mounted repo, so domain edits apply on the next reload too.
A dev, tuning or demo node keeps nothing, and its logs are part of that nothing — they are written
inside the container and go with it. Ask for them and they land on the host:
That gives you clientbuild.log, crud.log, spiral.log and serve.log in the directory you named — and
it also turns the log level up to INFO, which is what makes dispatch’s load timings visible. Dispatch
itself writes no file in either world: its output is the container’s own log (podman logs), or the terminal
when you launch bare metal.
Without it the node runs at ERROR, since detail nobody can read is only cost. HELIX_LOG_LEVEL overrides
either way, and HELIX_MONGO_LOG_DIR puts mongod’s own log somewhere separate.
This changed in 0.0.16. These three modes used to mount helix/runtime/storage/logs/ whether you asked
or not — one directory shared by every solo run from the same repo, so two of them interleaved into one set
of files and rotated over each other’s history. A node that keeps no world has no business keeping a record
of itself either. Cluster nodes are unchanged: they get storage/logs by default, because a production
node must be able to say why it stopped. See Storage and logs.
Requires only the helixCore image pulled — the container installs vite and @helix/client-sdk itself on
first run (the SDK registry is anonymously pullable, no token). Nothing else.
Export a config: ClientConfig from @helix/client-sdk:
templates — entity defaults, built with createTemplates({...}) (resource/project/desk/responsePoint).
helixOverrides — your implementations of helix.* functions referenced by the domain file
(return undefined to defer to the previous override / the default — see §6).
about — client name/version (shown in the About dialog alongside the published helixCore
version, which tracks releases automatically; git state/tag are build-injected).
Branding:clientLogo — the customer’s mark (top-right); helixLogo — the landing/masthead graphic;
themeColor — the accent colour (e.g. '#FFB500').
extraPanels — extra floating panels/components mounted in the dispatch view (e.g. a job card).
Contact Centre / contracts (optional):addContractForm overrides the generic “raise a contract” form,
and searchContracts supplies contract search results (merged with the built-in search over the domain
helix.searchPaths). See helixApp.
Editors (optional):
data extensions — a React panel embedded in the standard entity editor
(resourceDataExtension / projectDataExtension / deskDataExtension / hubDataExtension).
Each editor offers two regions over the same ext record: the hdr area
(resourceHdrExtension / projectHdrExtension / deskHdrExtension / hubHdrExtension) is
always the first panel and renders unwrapped — no section chrome — the natural home for the
entity’s identity fields (a crew’s ext.name/ext.tel, read by the core getName()/getTel()
doors); the ext area is the renameable Extension section, placed lowest in the form before
any stop editor,
stop templates — per-stop-type components (stopTemplates), used when a stop type’s
helix.templates is the string "component".
Data-extension contract. A data extension is a forwardRef<DataExtensionHandle, DataExtensionProps>
component. It receives { ext, onChange } — ext is the entity’s current client-extension blob and edits are
written back with onChange({ ...ext, ... }), persisted on the entity’s ext (e.g. project.ext.vehicle,
later read by an override via this.getRoot().ext). Expose checkContent(): boolean via useImperativeHandle
to gate Save (return false to block it).
@helix/client-sdk is the only import you need for types and helpers — never import from helixCore.
Text size. Operators can scale the interface text from ☰ → Text size. Give your panels’ own font sizes
with the SDK’s textPx so they follow it: style={{ fontSize: textPx(12) }}, not fontSize: 12. A fixed size
stays fixed while the rest of the screen grows around it. (textPx is in @helix/client-sdk 0.1.12 and
later.)
Archiving (optional). By default a deployment archives nothing — the framework is off until a
domain authors a helix.archive block (see the audit trail). When it is on, each
entity type chooses its destination with <entity>Params[type].helix.archive: omit it for the
built-in sink (a parallel database), set null to accept-and-discard, or name a function —
"myArchive()" — resolved from crudClientModule.cjs exactly like a lifecycle hook. The function
is called this = the entity JSON (or null), with info.audit the entries being archived and
info.m the Mongo accessor; return true once the data is safely stored and Helix then deletes
the live copy. It must be idempotent — after an interruption it may be called again with the
same data — and a completed entity yields two calls: the record with its trail, then a final call
with just the closing entry (this = null). Return anything but true to hold the data live and
retry later.
Most client behaviour is data, authored in <CLIENT>.domain.json (and .map.json) — not
code. On a stopParams (or resourceParams) entry, the helix block configures the frontend:
icon, tag, lineColor, per-state colours,
warning — a map of Spiral warning code → operator-facing template (with {attr|format} placeholders),
templates — named “option” templates: the operator picks a work type and its set (option array + any
attributes) is applied to the stop; values may reference other props as {prop|default},
addStop / mandatoryStop / morphStop — the add/morph-stop menus.
Spiral-side fields (delays, costs, skills, appt rules, etc.) also live here and feed the optimiser. Rule of
thumb: change wording/behaviour in the domain file; only reach for clientConfig.tsx for genuine React UI.
Stop types inherit prototypally — each stopParams entry clones another and the chain bottoms out at a
single base. Keep two kinds of property strictly apart, because they follow opposite rules:
Spiral fields (delay, noSoln, init, arrivalIs, appt rules, costs, …) are context-agnostic:
Spiral supplies its own built-in default, so they are often left unset. Don’t restate a Spiral default just
to make it visible. They belong on the shared base or on the specific leaf stop that needs them.
The helix block (rendering/behaviour) is context-sensitive — it legitimately differs by the role
a stop plays.
So the base is a context-agnostic default (the Spiral/business base — the shared Spiral fields, and
no helix) plus three role roots, each clone:"default" and carrying only its own helix:
Role root
for stops attached to
rendering foundation (shared/helixDefaults.ts)
projectStop
a project — the job/dispatch stops
helixProjectStopDefaults
responsePointStop
a response point
helixResponsePointStopDefaults
unprodStop
a resource’s unproductive / roster schedule
helixRosterStopDefaults
The roots mirror the helix-only foundations the frontend already merges under a stop by context; making
them explicit in the domain means the helix overrides live one level down, per role, rather than being
smuggled onto the shared base.
The rule — never put a Spiral value in a role root. A Spiral field on projectStop /
responsePointStop / unprodStop is a smell: it overrides Spiral’s own default by context and hides the
true operation. Agnostic Spiral values go on default (if a genuine client-wide override) or the specific
leaf type. Role-defining Spiral flags — an unprod stop’s nonProductive / isBreak — belong on the family
type (e.g. UNPROD_STATIC), not on the unprodStop root.
AA is the worked example: default holds ~25 Spiral fields and no helix; the project rendering (icon,
addStop, morphStop, per-state colours) lives on projectStop; the incident/recovery family (immediate
→ RSS / RCY / UNLOAD / …) clones projectStop; the UNPROD_STATIC family clones unprodStop.
Values like "helixColorDelay()" are expressions bound to functions in your helixOverrides (and the core
clientModule), evaluated with this = the stop/resource (StopContext). An expression is "fn" or
"fn('a', 'b')" — positional string arguments are supported, so a single function can serve many fields:
e.g. HRA’s field auto-sources use "vehicleField('transmission')", "vehicleField('drivetrain')",
"vehicleField('grossWeight')", plus "needsHeavyRecovery()" and "getNewProjectColor()". {prop|default}
placeholders interpolate values with a fallback. Your helixOverrides are picked up automatically —
they travel with the extension, so nothing needs declaring. (HELIX_CLIENT_MODULES in config is only for
an additional named module built into the host; you do not need it for your own overrides.) The override
object is typed HelixOverrides & Record<string, unknown> — the named HelixOverrides interface covers the
standard roster / list-text functions; your own domain-expression functions ride on the Record intersection.
Trip hazard: a name nothing defines fails silently, and open. When no module defines a function, the
expression is not an error — helixParameter warns to the browser console and returns the string
itself, which is then used as the value. A colour becomes the literal "myColour()" (invalid CSS, so the
element renders wrong rather than blank); a boolean gate such as a diagnostic option’s when becomes a
non-empty string, which is truthy — so the gate passes and the option always shows. Nothing throws and
nothing appears in a server log.
A typo in a domain expression therefore looks like a behaviour bug, not a config one. Two checks settle
it in seconds, both in the browser console:
Loaded helix module: honeywickHelix — your overrides were registered at all.
helixParameter: f[yourFn()] name[yourFn] … not found, returning as string — that name is unresolved.
Get into the habit of reading the console once after adding a domain expression. This is the one class of
client error the platform cannot fail loudly on: the domain is data, and a function name is only known to
be wrong at the moment it is needed.
Typed stop access. Inside a helix function, this (StopContext) exposes:
this.getInStop() → the authored InStop (input attributes — type, option, minLeaveIn,
to/and/with/…). Input-only fields such as minLeaveIn live here, not on data.
this.getRoot() → the owning entity’s root data, including the client ext — this is how overrides
read data entered by a data extension, e.g. this.getRoot().ext.vehicle or this.getRoot().ext.createdAt.
this.next() / this.after() → the next / previous stop in the resource’s schedule, and this.parent() → the
stop this one hangs off in the same entity (null for a root stop). Each is a direct lookup, and each works for
roster stops too.
plus this.getTime() and this.resource().
StopContext, InStop and OutStop are exported from @helix/client-sdk.
Override chaining. A name resolves through the loaded helix modules — your helixOverrides first, then
the core clientModule. A function that returns undefined defers to the previous override, so you can
handle only the cases you care about and let the default behaviour apply for the rest. For example, a colour
function that returns 'blue' for project (non-to) stops in their first two minutes and undefined
otherwise leaves every other stop on its default colour. Only when no module defines the name at all is the
literal string used as-is.
Author a domain master list in the app-level {"define":{"helix":{ … }}} block (a {"set":{"helix": … }}
block also works) to group skills and to name the surge domains. It is opt-in — without it the skills
editor stays free-form.
Each skill is typed skill (a boolean capability → null), constraint (→ { "max": n }) or capacity
(→ { "capacity": n }). The same domain names are used three ways (see
helixAppObject):
A type’s default group — helix.domain on a stop / resource / project type sets which group the skills
editor opens on (Helix-only; not sent to Spiral). A desk has none — its domain is a transient editor lens.
Surge factors (Spiral 2.4.0a) — a top-level domain block on a stop / resource type authors surge
factors per alert level. Stop-side factors are priority / ATA; resource-side are travel / demand:
A live surgeZone names { "domain":"Road", "alert":"RED", "active":true } plus geometry; every stop / resource whose type declares that domain gets the matching
factors within the zone. Any skill or surge factor referenced but not in helix.domains, or a factor on the
wrong side, is flagged with a warning at domain-seed time (the seed still completes).
Core icons ship in the image; client icons are bundled from your extension and resolved by name. The core set
is car, van, truck, motorcycle, tow, recovery, battery, puncture, triage, tick, lock,
unknown, plus the trade vans — van-white and van-silver (plain bodies) and the silver body carrying
a trade glyph: van-plumber (tap), van-heating (flame), van-builder (hammer), van-electrician
(lightning). A domain-file-only client, which ships no icons of its own, chooses from exactly this set.
The Administration app binds a user to the resources they may act as (user.actsAs), and the Engineer
view lists those resources to sign in as. Both pickers label + search a resource by an ordered list of
resource field paths you configure app-level:
The label is the first present field (falling back to type / #id); the search box matches the
resource id or any listed field. Unset, it falls back to ext.name → ext.callsign.
Some logic must be authoritative and server-side — validation that rejects a bad edit, a field stamped
once on creation, a side effect on a state change. That belongs in the crud (backend), not the frontend. The
crud loads an optional crudClientModule.cjs from your custom/ dir (a CommonJS module, merged over the
core) and calls its named functions wherever the domain references them. Each is called with this set to
the entity/stop; lifecycle hooks also receive a CrudHookInfo first argument. (The file is .cjs so it stays
CommonJS even though the extension package is "type": "module" for the vite frontend build.)
Domain reference
Runs on
this
Purpose
stopParams[type].helix.check
crud input (add/update)
the stop
return a non-empty string to reject the mutation
<entity>Params[type].helix.onCreate / .onDelete
crud input, live only
the entity
entity lifecycle (e.g. stamp a field on creation)
stopParams[type].helix.onState[from][to]
Spiral output (state change)
the stop
react to a stop transition
Live only.onCreate/onDelete fire for genuine live mutations (after scheduling has started) — not the
initial bulk load or file replay. onCreate runs before the mongo write, so a field you set on this is
persisted by that same add.
Stop matrix.onState is a per-stop-type, cell-by-cell map. The string 'null' is the pre-create edge
(null→first state) and the post-delete edge (last state→null). Transitions are read off Spiral’s output,
where both the old and new state are known.
Entity coverage. The four input entity types carry onCreate/onDelete on their params block —
projectParams, resourceParams, responsePointParams and deskParams. projectParams[type].helix.root
also names the project’s root stop type. (For example, HRA wires projectParams[...].helix.onCreate = "stampCreated()" to stamp ext.createdAt on a new project.)
CrudHookInfo — { now, live, verb, entityType?, fromState?, toState? }. now is the operational
backend time in epoch seconds (wall-clock plus Spiral’s offset, the backend analogue of the frontend clock).
A spec is "fn" or "fn(a, b)" (positional string params follow info). Hooks must be quick and must not
throw to block the mutation — failures are caught and logged.
Author the module as CommonJS so the crud can require it; a JSDoc @type gives type-checking against the SDK
(CrudClientModule, CrudCheckFn, CrudLifecycleFn, CrudHookInfo, EntityType, StopStateKey):
Most actions need no code — a state gate and literal text declared on the stop type (the
Actions guide is the authoring reference). Reach
for a client-module function only when the trigger depends on something a state cannot express. The
text expression names a function in your extension’s helix module; it runs with this bound to the
stop context and returns the message when the flag should fly, null when it should not:
// "an expensive resource is on a cheap job" — the flag flies only while that is true,
// and stays quiet once an outcome has been recorded on the stop.
exportfunctioncostToServeCheck() {
if (this.actions?.costToServe!==undefined) returnnull; // already ruled on
const cost = this.getResourceCostEstimate?.(); // your own signal
return (cost!=null&&cost>250) ?`Cost to serve ~£${cost} — check before deploying.`:null;
}
The rule is responsible for its own lasting suppression (consult this.actions[<name>]); a state-gated
action gets that quietness free. Rank arbitrates when several rules fire on one stop, and doubles as the
raised action’s urgency.
Post an event, gate it, act on it. The hooks above fire when something changes. A timed event fires when a moment arrives. The crud keeps a
single sim-time queue of future events and drains the head roughly once a second on the operational clock (wall
plus Spiral’s confirmed offset, so it never runs ahead of the optimiser); a per-drain cap bounds a herd of
events that all come due at once. Every event has the same four parts:
Part
Question it answers
Form
post (raise)
should this event exist for this entity right now?
the entry gate — truthy (re)schedules it, falsy drops it
when
at what moment does it fire?
an absolute time in epoch seconds
if
is it still valid when the moment arrives?
the fire-time gate — re-checked against fresh data; false ⇒ do nothing
then
what does it do?
the step — the automated work item (see processes, steps and actions): returns a field-object, committed as a force on the addressed entity
An event is addressed by a spiralKey ({ key, id, path }) — the entity or nested stop it applies to. That
key both binds this for the clauses and is where then’s result is written: e.g. then returning
{ state: "HEAD" } becomes a force that advances that stop, and { skills: null } on a resource key clears its
skills. Posting is idempotent — an event is keyed, so re-posting from fresh data replaces the queued one
rather than piling up, and a stale event (its if now false, or its entity gone) simply does nothing when it
fires, so you never have to cancel one by hand.
You author the clauses as ordinary helix.* expressions ("fn()" / "fn(a,b)") — the named functions live in
this same crudClientModule.cjs, called with this = { data: <entity>, now, params }. The core ships a set for
the resource simulator (simInState, simDelay, simAt, simSetState) and you override or add your own.
Two built-in surfaces ride this queue:
The resource simulator — stopParams[type].helix.event.simulator.<name>.{ raise, when, if, then }
(headStop DPLY→HEAD, arriveStop →ARVD, completeStop →DONE). This drives the world forward during
scenario replay only; override a clause per stop type to change how a demo behaves. For example:
// stopParams["Recovery"].helix.event.simulator.headStop.then = "simSetState(...)" is the default;
// override `when` to make acceptance slower for this type:
slowHead() { returnthis.data.dep+600; }, // fire 10 min after departure
Off-duty roster periods — the head roster stop’s std → dueOn → late → noShow boundaries
(see resourceParams → Off-duty shift-state).
Each period’s on is the action (then) and it is gated to fire only while the resource is still not
signed on (the fire-time gate) — on: "stripSkills()" clears a no-show’s skills; on: null is a
display-only period with no action. These run live as well as in replay.
The notification toolkit is core; the vendor channels are yours — a small
adapter object exported from this same crudClientModule.cjs, credentials read from your environment (they
never touch core or the repo). An adapter is one required method and one optional one:
Method
Direction
Job
send(rec, reply)
outbound
deliver one notification; return {ok:true} or {ok:false, error} (core retries with backoff, and raises a dispatcher action when retries exhaust)
receive(req)
inbound
decode a provider callback landing on /notify/inbound/<channel> — verify the provider’s signature (the raw body is preserved on req.rawBody), then return {token, outcome} or {target, outcome} (core matches a bare target — an SMS from-number — to its open notification)
WhatsApp (Meta Cloud API — plain HTTP, no SDK). Interactive buttons carry the reply token, so the
answer needs no free-text parsing:
helix/custom/crudClientModule.cjs
const crypto = require('crypto');
const WA = (path, body) => fetch(`https://graph.facebook.com/v21.0/${process.env.WA_PHONE_ID}/${path}`, {
Point the provider’s webhook at https://<your-helix>/notify/inbound/whatsapp (or /sms). Per-resource
addressing lives on the resource record — ext.notify: { whatsapp: "+44…", sms: "+44…", smtp: "a@b.c" }
(ext is Helix-only and never sent to Spiral). Enable channels per roster block:
resourceParams…rosterStop.STBY.helix.callout.notify: ["whatsapp","sms"]. Two rules the framework keeps
for you: simulation never sends (a replayed world writes suppressed records instead of ringing real
phones — HELIX_NOTIFY_FORCE=1 is the deliberate dev override), and replies compose the standard
helpers (acceptCalloutById, processCalloutRefusal) — an adapter never builds crud commands itself.
For genuinely client-specific reply kinds, export notifyReplyHandlers = { myKind: async (ctx, rec, outcome) => '…status…' }
— core routes any reply whose payload.kind matches before its own kinds.
A client can run one process of its own inside the node, beside Helix’s — a lookup against a third-party API,
say, that the SPA calls same-origin. Helix proxies one path to it, so the browser never sees a second port.
Declare it in helix/custom/config:
Terminal window
exportHELIX_CLIENT_SERVICE="service/app.js"# the compiled entry point, beside the built client
exportHELIX_CLIENT_SERVICE_PORT="3013"# the port it listens on, inside the node
exportHELIX_CLIENT_SERVICE_PATH="/vehicle"# what Helix proxies to it (default /service)
Write it in TypeScript under helix/custom/service/ and give package.json a build:service script that
compiles it to CommonJS in helix/runtime/service (beside helix/runtime/client, where the extension
builds). CommonJS because the service can require the core’s own packages — express, the mongo driver —
without shipping them; NODE_PATH points there, and it applies to require only. The HRA demo’s HaynesPro
vehicle lookup is the worked example (Demos/HRA/helix/custom/service, tsconfig.service.json).
start dev builds it before starting it; helixctl release builds it and packs service/ into the artefact
beside client/, so every node started from that release — sealed, demo or tuning — runs the same code. A
service that is declared and cannot be found stops the node at start, naming the path it looked for.
The @helix/client-sdk version is the contract between your extension and the platform — not the core
image version. You pin the SDK minor line (helix/runtime/VERSION = sdk-0.1) and the core image floats: every
helixctl pull gets the newest core still on SDK 0.1.x.
The image carries its own core + SDK version (baked at /app/VERSION and /app/SDK_VERSION, exposed as
the helix.version / helix.sdk labels).
helixctl version reads them back from the image and prints: the pinned tag (helix/runtime/VERSION, e.g.
sdk-0.1), the true core version behind that float (e.g. 0.0.0.b), the image’s SDK, and — from the
deployed bundle’s manifest — your client’s SDK and the core the bundle was built against. That last
pair is the line to quote when describing a node: what it runs, and what the deployed bundle was
validated on.
helixctl <CLIENT> start checks minor compatibility: a bundle built against 0.1.x runs on any 0.1.x core,
but is refused on a 0.2 core. When Honeywick releases a breaking contract (0.2), bump your
@helix/client-sdk dependency, rebuild, and re-pin helix/runtime/VERSION to sdk-0.2.
The running Spiral version is separate from the core image and is reported by the optimiser itself at
startup. It is shown in the in-app About dialog (and served at GET /spiral/version) — version,
branch, commit, compiled.
build:client is Vite, which transpiles without checking types. On its own it will happily build an
extension that no longer matches the SDK, and you find out in the browser. So your repo carries a
tsconfig.json and a typecheck script, and build:client runs it first:
"scripts": {
"typecheck": "tsc --noEmit",
"build:client": "npm run typecheck && vite build -c vite.client.config.ts"
Include vite.client.config.ts: it is TypeScript that runs at build time, and a fault there breaks the
bundle just as surely as one in the extension.
This is what makes §8’s contract real on your side. When Honeywick ships a new SDK, npm i @helix/client-sdk@<version> && npm run typecheck tells you in seconds whether anything you use has moved
— before you build, deploy, or discover it live.
crudClientModule.cjs is not covered: it is CommonJS JavaScript, and its
@type {import('@helix/client-sdk').CrudClientModule} annotation only enforces under allowJs +
checkJs. Enable those if you want the backend hooks checked too.
The build runs inside a container of the core you are releasing against, so this needs no node or npm on
the machine you run it from — the same promise helixctl check makes for dev.
That is the bundle on its own, for a node that already runs the core you want. To ship the core with it, or
to ship one image with everything in it, see
Releasing to test or production below.
The action is always start, and the type is its argument. Start from what you are trying to do:
I want to…
helixctl <CLIENT> …
develop the extension — edit clientConfig.tsx and see it
start dev
tune the domain — edit the JSON, no rebuild
start tuning
try or show the product
start demo (in your repo)
run a public demo instance or a tuning lab
release --as studio, then start demo <artefact>
run it for real, with data that survives
create <name>, then start replicant
survive losing a node
create + start replicant + a third vote
a regional viewer, reporting offload, or a durable copy
start standby
drive the optimiser from another program, keeping a live UI
start spiral
use the optimiser as a command-line filter
start spiralOnly
A start type is not a mode, and that is the one thing worth holding on to. A start type is what you ask
for; a mode is the role the node then plays, and there are four of those: solo, multiplexor,
replicant and standby. demo, dev and tuning are all mode solo — one role started three ways — and
what separates them is the environment, a name the client chooses (live, test, acceptance, …), which
says how seriously to treat the instance and who may write to the mounted repo.
All three of them mount the client repo, and that is what makes them the simulators. Scenarios and run
files reach a node exactly one way — a mounted repo — because release does not put scenarios in a bundle.
Nothing sealed mounts a repo, so nothing sealed can simulate, so a production node never carries either. There
is no start type called live; an environment of that name is a different vocabulary. startHelix prints both
levels in its boot line, and Mission Control shows them side by side.
primary is not in that list either. The primary is whichever replicant currently holds the write,
decided by an election and observed from mongo. You never start a node as the primary; you start it as a
replicant, and it may win.
What each one actually is:
demo — the mode a public demo server runs: mode solo, an environment whose properties allow a walk-up
(any email signs in, no password), a unique container name and a fresh tmpfs world so many can run at once, and its own
copy of the client repo, made inside the container. The copy is fully writable, so a visitor can tune the
domain and save a run file for real — and it dies with their container, while the host’s tree is mounted
read-only purely as the thing the copy is made from. It builds nothing, so it serves whatever
runtime/client already holds; run dev once first, which it will tell you. Not durable — for data
that survives, use create.
demo and tuning are the same node with one difference:demo works on a copy, tuning works on the
actual tree, so a tuner’s saves persist and can be committed. Everything else — mounting the repo, building
nothing, simulating — is shared.
It takes an argument, and which kind decides where the repo comes from. In your own repo you give it
nothing (or a path) and it mounts the working tree, as dev and tuning do. On a demo server there is
no repo — so name an artefact instead and it fetches one:
Terminal window
./helixctl<CLIENT>startdemo# this repo's working tree
./helixctl<CLIENT>startdemo/path/to/repo/helix# another tree already on this machine
./helixctl<CLIENT>startdemohelix-<client>-studio-<ver>.tar# restore the repo from the release
./helixctl<CLIENT>starttuninghelix-<client>-studio-<ver>.tar# the same, as a Laboratory
A studio release (release --as studio) carries the repo’s own source inside the artefact, as a git
bundle at the commit its manifest records — so one file holds both halves of what a demo serves: the
built client, and the source that client came from. It is restored into storage/source, and every later
start finds it already there. It needs git on the machine and no credential of any kind — see
Stand up a demo server. A plain --as bundle release carries no
source and is refused, naming the form to use.
dev — mounts your client repo and runs vite build --watch inside the container: edit source, ~1s
rebuild, refresh. Installs your extension’s dependencies on first run, so the host needs no node or npm.
tuning — mounts the repo exactly as dev does but builds nothing, serving whatever bundle
runtime/client already holds. That is what you want for domain work: the domain is JSON, read at start-up
and on reload, so a watcher would only churn your working tree. It fails immediately if no bundle exists —
run dev once first. This is the home of the Spiral Laboratory:
give it a live vault read-only (HELIX_VAULT_DIR_HOST=<dir>:ro) to
extract a moment from a production system, and a directory of
Spiral builds (HELIX_CANDIDATES_DIR_HOST) to test a release before it ships.
spiral — the fed anchor, mode multiplexor: the full stack, but crud takes its commands on stdin and
echoes every output record on stdout, so a program that already pipes to a Spiral binary keeps its pipe and
gains a live SPA over what it is feeding in. Read-only except forced writes. It is joinable — see below
— and needs HELIX_PUBLIC_HOST. One per host. It lasts as long as its input: when stdin closes, Spiral
finishes what it was given and the container stops, returning Spiral’s exit status — the same as the bare
binary would.
spiralOnly — the bare engine: one process, stdin/stdout/stderr passthrough, no mongo, no API, no
ports. The drop-in for anything already driving a Spiral binary as a filter, and the mode to use when you
want several concurrent runs on one box. In practice you reach both this and spiral through the
launch commands: they run a <Client>_helixConfig.tar on the
core it was built with, overwriting the engine, domain or map with any found in the working directory.
create / replicant / standby — the cluster. All need HELIX_PUBLIC_HOST; a joiner also needs
HELIX_PRIMARY. create makes a new cluster, takes its name (helixctl <CLIENT> create <name>, or
HELIX_CLUSTER=<name>), refuses if this machine already holds a world, and is the only one of the three
that is not a start — what it leaves behind is an ordinary replicant. The name is then kept in the world
itself for every member to inherit.
start replicant covers both of the remaining acts and reads which from what it finds: given a
HELIX_PRIMARY it joins that set, given none it brings back the world already on this machine, and given
neither a world nor a primary it refuses — which is what stops a mistyped command creating a second cluster.
start standby joins at priority 0: it keeps its mongo files current and serves reads but can never be
elected, so it never runs crud and never runs the optimiser. It is where the
vault belongs — give it HELIX_VAULT_DIR_HOST and it writes
the dumps, operation-log slices and command stream a tuning node extracts from. Without one it is simply a
member that holds a durable copy, which is what a fed anchor has no way to do for itself.
A two-node cluster does not fail over. MongoDB elects on a majority of votes, so the node you
create plus one replicant is two votes: lose the first and the survivor holds 1 of 2, is never
elected, and you have no writer and no optimiser rather than a degraded cluster. Run three voting
members — a third machine as replicant, or a standby (which votes for quorum but can never be
elected, so it never runs the optimiser). The third vote only helps if it is in a separate failure
domain; beside the writer it buys nothing. Note also that member flags are set when a node joins:
adding the third node fixes a new cluster, while a member already in the replica-set config keeps the flags
it joined with until an rs.reconfig — redeploying its container changes nothing.
A fed anchor is joinable, and that is why it needs an address.spiral initiates the replica set at
HELIX_PUBLIC_HOST just as a cluster anchor does, publishing mongo and CRUD so another node can attach to a live
feed and keep a durable copy of what is being pushed through the pipe:
Terminal window
HELIX_PUBLIC_HOST=192.168.0.9./helix/runtime/helixctl<CLIENT>startspiral# prints the join line
./helix/runtime/helixctl<CLIENT>startstandby# member, on another box
The address is what other machines use to reach this node — a name or IP they can route to, written
verbatim into the replica-set config, so localhost records something no member can ever reach. It is not
the licence identity: that is the container’s --hostname, pinned to the host’s own name.
The feed’s own database is still tmpfs and dies with the container — keeping a durable copy is the
joining member’s job. Only the pipe writes, and the fixed published ports mean one feed per host; use
spiralOnly when you want several at once.
Licence every node in a cluster, not just the one holding the write. Spiral’s licence is keyed to the
hostname it sees, and helixctl pins the container’s hostname to the host’s for exactly that reason. A
replicant runs no optimiser until it is elected — at which point it launches crud, which launches
Spiral, on its own hostname. Licence only the first node and a failover silently degrades to
Licence exceeded. Optimisation is OFF. rather than refusing to start. HELIX_HOSTNAME overrides the
pin where the licensed name differs from the machine name.
HELIX_LOG_DIR_HOST=<dir> bind-mounts logs to the host.
HELIX_SSH_DIR_HOST=<dir> names the ssh identity the node may use to reach another machine. You rarely
set it: by default the launcher lends the launch account’s own ~/.ssh (read-only) and its agent.
HELIX_SSH_DIR_HOST= — explicitly empty — lends nothing.
Every mode runs the same client-agnostic core image. What differs is how your client — domain, map,
config and extension — gets inside it, and whether the container can write back to your repo:
You run
Your client arrives as
Writes to your repo?
So it runs…
helixctl <CLIENT> start dev
your helix/ directory, bind-mounted
yes — the rebuilt bundle lands in helix/runtime/client
a <Client>_helixConfig.tar from the working directory (else $HELIX_CONFIG), with any domain / map from the working directory rotated in, re-packed into a cache on the host and bind-mounted read-only as client.tar
Inside the container there are two cases. dev and tuning read your repo directly. Everything else needs
a client.tar: the core unpacks it into a private temporary tree and refuses to start without one — there
is no fallback to reading a directory, and the tar itself is never written to. spiralOnly is the lightest
of those — the unpacked config is Spiral’s working directory and nothing else is started — but it arrives the
same way as all the rest.
So only dev and tuning can change your working tree. Because they do, their build output is written as
your user rather than root, and your extension’s dependencies are installed into a separate named volume, so
container-built native modules never appear in helix/custom/node_modules.
The core and your extension version independently, which is the point of pinning the SDK minor:
Terminal window
./helix/runtime/helixctlpull# newest core on your SDK line — see below
./helix/runtime/helixctlimporthelix-<client>-<version>.tar# only when YOUR extension changed
helix/runtime/VERSION holds a floating tag (sdk-0.1), so pull fetches the latest core built for
that SDK line. Two things follow, and the second is the one that catches people:
Nothing upgrades on its own.helixctl <CLIENT> start demo|dev never pulls. It pulls implicitly only when
the tag is absent locally; once you hold any copy of sdk-0.1, it is used indefinitely however old it
is. helixctl pull is the only way to move a tag you already have.
The launcher upgrades itself. Your committed helix/runtime/helixctl is a bootstrap stub; the real
launcher is extracted from the image and cached per image id, so a pull brings a new launcher with it.
No copying, no re-sync.
Confirm what you actually got with ./helix/runtime/helixctl version — it prints the pinned tag, the true
core version behind that float, the image’s SDK and your client’s.
Two versions govern a deployment, and only one of them is a contract:
where it lives
how to treat it
SDK — the contract
helix/custom/package.json, exact
pinned; recorded in the bundle manifest; start refuses a mismatched minor
Core image — the engine
helix/runtime/VERSION
sdk-0.1 while you develop; exact for a release
Float in the repo, pin at release. Leave helix/runtime/VERSION on the sdk-<MAJOR.MINOR> tag on your
main branch so the dev loop picks up core fixes on a plain helixctl pull. A release then pins the exact core
it was signed off on — and does it for you.
./helix/runtime/helixctlrelease [version] [--as studio|set|bundle|image|core-set] # version from package.json
Whichever form you choose, release resolves your pin to an exact core, builds your extension inside a
container of that core, and refuses if the two disagree. Nothing needs node or npm on the machine you run it
from.
--as
what you hand over
how the node takes it
when
bundle
helix-<client>-<ver>.tar
helixctl import <tar>, then start <type> <tar>
the node already runs the core you want
set(default)
one tar: core image + bundle + launcher + pin
helixctl install
air-gapped, or a controlled production estate
image
one image, everything baked in
helixctl pull
you deploy from a registry
core-set
the core alone, no client
helixctl install, then import a bundle
a special case — bootstrapping a machine that has no core yet, or an air-gapped estate where the core travels separately
echoregistry.example.com/helix>helix/runtime/CLIENT_REGISTRY# once
./helix/runtime/helixctlrelease1.4.0--asimage# builds and pushes; --no-push to hold back
# -> registry.example.com/helix/<client>:1.4.0
The image is the exact core with your sealed bundle baked in, labelled with the client, its version and its
SDK, and inheriting the core’s own version labels. On the node there is nothing to deploy:
Terminal window
# helix/runtime/REGISTRY = registry.example.com/helix/<client>, VERSION = 1.4.0
An image cannot serve demo. The three solo types mount a repo — that is what makes them simulators — and
an image carries none, so scenarios and run files have no source. Use an image for a durable node, and
--as studio where you want a demo or a tuning lab: that form carries the repo inside the artefact.
helixctl reads the image’s label at launch and mounts no bundle. It refuses if the node also has a
deployed bundle, or if the image is baked for a different client — one node, one answer to “what is this
running”.
spiral, spiralOnly, create, replicant and standbyrefuse to start when the image is
latest or sdk-*:
helixctl: 'replicant' is a live mode but the image is the floating tag 'sdk-0.1' (currently core 0.0.5).
A live node must state what it runs. Pin it:
helixctl pin # writes 0.0.5 to helix/runtime/VERSION
dev, tuning and demo may float.
A float names a moving target, so a node on one cannot answer “which core is this?” — which is exactly the
question you need answered when a deployed system misbehaves. helixctl pin resolves the float and writes the
exact tag; a release arrives already pinned, so you will only meet this after a plain pull.
dev, tuning and demo are exempt by design. They mount your repo and switching core is part of the
job — that is the difference between a working copy and a release.
Versions and upgrades covers the rest: which of the three
versions is actually enforced, how to read what a node is running, and how to upgrade a node or a fleet —
including the case where an older node still pinned to latest refuses to start after an upgrade.
./helixctl version reports the same from the host side — image, pinned tag, true core, the image’s SDK, and
the deployed bundle’s SDK and core (or the client baked into the image).
docker-archive is the one format both podman and docker load. Drop the file into helix/runtime/ and it
is loaded the first time the image is absent; helixctl load <tar> forces it. With more than one archive
there, load names them and stops rather than guessing which core you meant.
A privileged console for the running instance, reached from the landing page’s Advanced Options (not
the application row: it is not a surface you work in). It needs the privileged permission, which
admin does not imply — administering users and stopping production are deliberately different
authorities.
It shows one card per node — its mode and environment (solo · dev, replicant · live), host, and
state — including nodes that are UNREACHABLE (a set member the primary cannot reach) or MISSING
(known to Helix but no longer a replica-set member). A failed node keeps its card rather than vanishing
from the list, which is the point: the node you cannot see is the one you need to find.
The primary’s card carries the two actions only it can perform — step back (hand the primary role to
another member, the normal way to drain a host before maintenance) and restart Spiral (there is one
optimiser per instance, on the writer). Separately, and deliberately not styled as a node, a red shut
down instance stops every node — secondaries first, then the writer, each draining its queue and closing
the optimiser cleanly. Nodes run as disposable containers, so restarting them needs a shell on each
host; the confirmation says so.
Restart Spiral used to sit on the landing page. It moved here, and now requires privileged.
Could a machine host a node? (0.0.14) Mission Control can also ask a machine that is not yet part of the
cluster whether it could be — what Helix is installed there, how it got there, and which modes that install
could actually launch. Everything else a cluster does travels through the shared database; this cannot,
because a machine not running Helix has nothing to write to it with. So it is the one place Helix reaches
another machine directly, over ssh, asking a single restricted verb and reading one JSON object back.
It borrows your reach rather than keeping any of its own: Helix generates no keys and stores no
credentials. The launcher lends the node the launch account’s ~/.ssh, read-only, and its ssh-agent if
one is running — so the rule is simply that the node can do what a non-interactive ssh from that account can
do, and nothing more:
Terminal window
ssh-oBatchMode=yes<other-host>true# as the account that launches Helix
If that fails, so will this, and the console says which side is at fault rather than reporting the far
machine as broken. The case to know: a Kerberos ticket or a typed password works at a prompt and cannot
cross into a container, so an account whose ~/.ssh holds only a known_hosts needs a keypair of its own.
Set up the far machine’s authorized_keys with a forced command if you want to bound what that key may
do there — the agent answers one verb, so the confinement is real.
Rootless podman hands a container’s network to a userspace helper. helixctl asks for pasta when the
host can do it — podman ≥ 4.4, rootless, with pasta installed — and otherwise says nothing and lets
podman choose. pasta is faster than the older slirp4netns, preserves the client’s address, and is
podman’s own default from 5.0, so this moves with the grain. Docker never sees the flag (it has no such
mode) and neither does rootful podman (it uses the netavark bridge instead).
helixctl check reports which helper is in play, e.g. podman 4.9.3 ✓ (rootless, pasta). To pin it:
Terminal window
HELIX_NETWORK=slirp4netns./helix/runtime/helixctl<CLIENT>startdev# per run
Host-wide, if you want every rootless container on pasta rather than just Helix’s:
~/.config/containers/containers.conf
[network]
default_rootless_network_cmd = "pasta"
One behaviour to know: pasta forwards a published port preserving the client’s address family, so a
container service listening IPv4-only cannot answer a connection arriving over IPv6. Helix’s
serve.js binds dual-stack, so every address works — but it is why a naive curl localhost against an
IPv4-only test container can look broken under pasta and fine under slirp4netns.
Domain-driven: prefer <CLIENT>.domain.json (stopParams.helix.*) over frontend code for wording and
behaviour.
Server-side truth: validation, lifecycle and state-change side effects belong in crudClientModule.cjs
(the crud), not the frontend — the frontend helixOverrides are for display only.
SDK only: depend on @helix/client-sdk; never import helixCore internals.
Keep the SDK version in step with the image you deploy against (helixctl version / the deploy check).
Your repo is independent of helixCore — version, branch, and release it on its own cadence.
Rebuild the bundle (build:client) after extension changes; re-publish + import to ship. It
typechecks first — a build that fails there has not produced a bundle, so fix the types rather than
reaching for vite build directly.
After bumping @helix/client-sdk, run npm run typecheck before anything else: it is the fastest
statement of whether the new contract still fits your extension.
Helix as delivered is an aggregate of three separately licensed parts, and the image tells you so itself —
/app/LICENCE.txt and /app/THIRD-PARTY-NOTICES.md ship inside it:
Honeywick’s Helix source — MIT. The platform, the frontend, the launcher and @helix/client-sdk.
Spiral (spiral.l64, spiralTravelOSRM.l64) — proprietary, and licensed separately to each
client. The binaries ship in the image, but possession is not a licence to use them, and Spiral will
not optimise without a key issued for the host it runs on (the key is bound to the hostname the process
sees — which is why helixctl pins --hostname). Talk to Honeywick about Spiral licensing.
Third-party components — MongoDB Community Server under the SSPL, the MongoDB shell, Node.js and
the npm packages, each under its own terms. The notices file carries the full inventory, verified per
release: no GPL, LGPL or AGPL anywhere in the runtime or the browser bundle.
If you redistribute the image, those notices and licence texts must travel with it.