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.
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.
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> 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>dev# e.g. ./helix/runtime/helixctl AA 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 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).
On Docker: the dev loop writes the built bundle into your working tree (helix/runtime/client) and
its logs into helix/runtime/logs/. 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 the clientbuild.log in the host log dir; 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.
Logs are bind-mounted to the host (so they survive the container) under the dir helixctl prints
(…/logs/dev/): clientbuild.log, crud.log, spiral.log, dispatch.log, serve.log.
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),
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.
Most client behaviour is data, authored in <CLIENT>.domain.json (and .map.json / .ui.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.
plus this.parent(), this.getTime(), this.next(), this.after(), 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).
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.
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 deploy checks minor compatibility: a bundle built against 0.1.x deploys 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.
Start from what you are trying to do, not from the list:
I want to…
mode
develop the extension — edit clientConfig.tsx and see it
dev
tune the domain — edit the JSON, no rebuild
tuning
try or show the product
run
run a public demo instance
demo
run it for real, with data that survives
master
survive losing a node
master + replicant + a third vote
a read-only regional viewer or reporting offload
readOnly
drive the optimiser from another program, keeping a live UI
spiral
use the optimiser as a command-line filter
spiralOnly
Mode and deployment class are different axes.run, demo and dev all report mode solo; the
class (dev / demo / live) is what says how seriously to treat the instance. startHelix prints both
in its boot line, and Mission Control shows them side by side.
What each one actually is:
run — a single self-contained node (solo is the same mode): unique name, tmpfs mongo, one published
port, so several can run at once on a host. Not durable — for data that survives, use master.
demo — run with the demo deployment class. It sets HELIX_DEMO for you, which quiets the auxiliary
logs and enables lead capture. The demo orchestrator drives this same word.
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. (Stub: its vault / crud2spiral replay is still pending.)
spiral — the fed primary: the full stack, but crud takes its Spiral 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.
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.
master / replicant / readOnly / snapshot — the cluster. All need HELIX_PUBLIC_HOST; all but
master also need HELIX_PRIMARY. snapshot is a stub: it joins as a hidden non-voting member and
runs a read-only stack, but the periodic capture it exists for is not wired — do not rely on it for backup.
A two-node cluster does not fail over. MongoDB elects on a majority of votes, so master + one
replicant is two votes: lose the master 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 readOnly (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 master it
buys nothing. Note also that member flags are set when a node joins: adding the third node fixes a
new cluster, while an existing readOnly deployed before Helix 0.0.3u keeps its non-voting setting until
an rs.reconfig — redeploying the container does not change a member already in the replica-set config.
A fed primary is joinable, and that is why it needs an address.spiral initiates the replica set at
HELIX_PUBLIC_HOST just as master 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>spiral# prints the join line
./helix/runtime/helixctl<CLIENT>snapshot# 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 master. 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 or
readOnly node runs no optimiser until it is elected — at which point it launches crud, which launches
Spiral, on its own hostname. Licence only the master 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.
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/helixctldeployhelix-<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> run|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; deploy 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 bundle|set|image] # version defaults to 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 deploy <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 deploy
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
./helixctlpull && ./helixctl<CLIENT>run
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”.
run, spiral, spiralOnly, master, replicant, readOnly and snapshotrefuse to start when the
image is latest or sdk-*:
helixctl: 'master' 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 — deployment class and mode (dev · solo, live · master), 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.
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>dev# 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 + deploy 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.