Skip to content

Develop a client extension

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.

1. What you own vs. what Honeywick provides

Section titled “1. What you own vs. what Honeywick provides”
You own (your client repo)Honeywick provides
clientConfig.tsx — config + React componentsthe helixCore image (platform: SPA + crud + dispatch + Spiral + mongo)
<CLIENT>.domain.json / .map.json — Spiral & UI config@helix/client-sdk — the typed extension contract (npm)
client icons, logo, templates, helix overrideshelixctl — 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.

<your-client-repo>/ # a git repo (e.g. AA)
helix/ # everything Helix, split into source vs runtime
custom/ # REQUIRED customisation — the extension source
<CLIENT>.domain.json # Spiral/domain config: stopParams, resourceParams, helix.* blocks
<CLIENT>.map.json # Spiral map
config # client env overrides (ports, client modules)
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:

Terminal window
podman login registry.gitlab.com # or: docker login registry.gitlab.com
./helix/runtime/helixctl pull # 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):

Terminal window
podman run --rm --entrypoint cat \
registry.gitlab.com/honeywick-consulting/helix/helixcore:sdk-0.1 /app/helixctl-bootstrap > helix/runtime/helixctl
chmod +x helix/runtime/helixctl

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.

4. Your dev loop — helixctl <CLIENT> start dev

Section titled “4. Your dev loop — helixctl <CLIENT> start dev”

Run from the repo root (the dir that holds helix/ and tests/):

Terminal window
./helix/runtime/helixctl <CLIENT> start dev # 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:

[startHelix] repo: /home/you/AA/helix — custom/ 25 files, scenarios/ 5, tests/ 2811 run files

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:

  1. Edit clientConfig.tsx, a component, or <CLIENT>.domain.json.
  2. Vite rebuilds the bundle in ~1s (watch clientbuild.log — see Logs below; the first run also logs the one-time npm install there).
  3. 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:

Terminal window
HELIX_LOG_DIR_HOST=$PWD/logs ./helix/runtime/helixctl <CLIENT> start dev

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,
    • full-control editors — replace the whole entity panel (resourceEditor / projectEditor / deskEditor),
    • 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 rootfor stops attached torendering foundation (shared/helixDefaults.ts)
projectStopa project — the job/dispatch stopshelixProjectStopDefaults
responsePointStopa response pointhelixResponsePointStopDefaults
unprodStopa resource’s unproductive / roster schedulehelixRosterStopDefaults

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.getOutStop() → the runtime OutStop (state, dep, arv, fin, tvl, spiralKey, …).
  • 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.

{"define": {"helix": {
"domains": {
"Road": { "recovery":"skill", "heavyRecovery":"skill", "seats":"capacity" },
"Home": { "lockOut":"skill" }
}
}}}

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:

    // on a stopParams entry
    "domain": { "Road": { "AMBER": { "priority":1.0, "ATA":1.0 }, "RED": { "priority":0.1, "ATA":1.5 } } }
    // on a resourceParams entry
    "domain": { "Road": { "AMBER": { "travel":2.0, "demand":2.0 }, "RED": { "travel":3.0, "demand":10.0 } } }

    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.

Act-as resource pickers (helix.user.actas.search)

Section titled “Act-as resource pickers (helix.user.actas.search)”

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:

{"define":{"helix":{ "user":{ "actas":{ "search":["ext.callsign", "ext.name"] } } }}}

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 referenceRuns onthisPurpose
stopParams[type].helix.checkcrud input (add/update)the stopreturn a non-empty string to reject the mutation
<entity>Params[type].helix.onCreate / .onDeletecrud input, live onlythe entityentity lifecycle (e.g. stamp a field on creation)
stopParams[type].helix.onState[from][to]Spiral output (state change)the stopreact 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):

helix/custom/crudClientModule.cjs
/** @type {import('@helix/client-sdk').CrudClientModule} */
module.exports = {
// projectParams["Recovery"].helix.onCreate = "stampCreated()"
stampCreated(info) {
this.ext = this.ext || {};
if (this.ext.createdAt == null) this.ext.createdAt = info.now; // epoch seconds
},
// stopParams["Recovery"].helix.onState["null"]["PLAN"] = "onPlanned()"
onPlanned(info) { /* a Recovery stop just entered PLAN (info.fromState === 'null') */ },
};

Action rules (helix.actions text functions)

Section titled “Action rules (helix.actions text functions)”

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.
export function costToServeCheck() {
if (this.actions?.costToServe !== undefined) return null; // 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.

(Concepts and the live/replay split: the Events guide. Clause reference: eventObject.)

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:

PartQuestion it answersForm
post (raise)should this event exist for this entity right now?the entry gate — truthy (re)schedules it, falsy drops it
whenat what moment does it fire?an absolute time in epoch seconds
ifis it still valid when the moment arrives?the fire-time gate — re-checked against fresh data; false ⇒ do nothing
thenwhat 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() { return this.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:

MethodDirectionJob
send(rec, reply)outbounddeliver one notification; return {ok:true} or {ok:false, error} (core retries with backoff, and raises a dispatcher action when retries exhaust)
receive(req)inbounddecode 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}`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.WA_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
module.exports.notifyChannels = {
whatsapp: {
async send(rec, reply) {
if (!rec.target) return { ok: false, error: 'no whatsapp number (resource.ext.notify.whatsapp)' };
const buttons = (rec.outcomes ?? []).map(o => ({
type: 'reply',
// The reply token rides in the button id — receive() below hands it straight back.
reply: { id: `${rec.replyToken}:${o}`, title: o.toUpperCase() },
}));
const res = await WA('messages', {
messaging_product: 'whatsapp', to: rec.target,
...(buttons.length
? { type: 'interactive', interactive: { type: 'button',
body: { text: rec.payload.text }, action: { buttons } } }
: { type: 'text', text: { body: rec.payload.text } }),
});
return res.ok ? { ok: true } : { ok: false, error: `WA ${res.status}` };
},
async receive(req) {
// Meta's webhook verification handshake (GET) — echo the challenge.
if (req.method === 'GET' && req.query['hub.verify_token'] === process.env.WA_VERIFY_TOKEN)
return { challenge: String(req.query['hub.challenge'] ?? '') };
// Signature check over the RAW body (X-Hub-Signature-256 = sha256 HMAC of the bytes).
const sig = String(req.headers['x-hub-signature-256'] ?? '');
const mac = 'sha256=' + crypto.createHmac('sha256', process.env.WA_APP_SECRET)
.update(req.rawBody ?? Buffer.alloc(0)).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(mac))) throw new Error('bad signature');
// A button reply: the id we sent is `<token>:<outcome>`.
const id = req.body?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]?.interactive?.button_reply?.id;
if (!id) return null; // delivery/status pings — ignore
const [token, outcome] = id.split(':');
return { token, outcome };
},
},
// **SMS (Twilio-shaped).** Free-text replies carry no token — return the from-number as `target`
// and core matches it to the resource's most recent open notification.
sms: {
async send(rec, reply) {
if (!rec.target) return { ok: false, error: 'no sms number (resource.ext.notify.sms)' };
const body = rec.payload.text + (rec.outcomes ? ` — reply ${rec.outcomes.map(o => o.toUpperCase()).join(' or ')}` : '');
const res = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${process.env.TWILIO_SID}/Messages.json`, {
method: 'POST',
headers: {
Authorization: 'Basic ' + Buffer.from(`${process.env.TWILIO_SID}:${process.env.TWILIO_AUTH}`).toString('base64'),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ From: process.env.TWILIO_FROM, To: rec.target, Body: body }),
});
return res.ok ? { ok: true } : { ok: false, error: `Twilio ${res.status}` };
},
async receive(req) {
// Verify X-Twilio-Signature over the exact URL + sorted form params (see Twilio's docs;
// omitted here for brevity — always implement it before going live).
const from = req.body?.From, text = String(req.body?.Body ?? '').trim().toLowerCase();
if (!from) return null;
const outcome = ['accept', 'yes', 'y', '1'].includes(text) ? 'accept'
: ['refuse', 'no', 'n', '2'].includes(text) ? 'refuse' : null;
return outcome ? { target: from, outcome } : null;
},
},
};

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
export HELIX_CLIENT_SERVICE="service/app.js" # the compiled entry point, beside the built client
export HELIX_CLIENT_SERVICE_PORT="3013" # the port it listens on, inside the node
export HELIX_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.

Enforce the contract locally — npm run typecheck

Section titled “Enforce the contract locally — npm run typecheck”

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"
},
"devDependencies": {
"typescript": "^5", "@types/react": "^18", "@types/react-dom": "^18", ...
}

The tsconfig.json mirrors how Vite actually builds, so the two cannot disagree about the same source:

{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler", // as Vite resolves — incl. the SDK's package exports
"jsx": "react-jsx", // @vitejs/plugin-react's automatic runtime
"strict": true,
"noEmit": true,
"skipLibCheck": true, // don't re-check deps' .d.ts; YOUR use of them is still checked
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true, // esbuild compiles file-by-file — reject what it can't handle alone
"forceConsistentCasingInFileNames": true,
"types": ["vite/client", "node"] // vite/client covers `?url` asset imports; node covers the build config
},
"include": ["frontend", "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.

Build the bundle:

Terminal window
(cd helix/custom && npm run build:client) # typecheck, then vite -> ../runtime/client (clientConfig.js [+ icons])

Pack a deployable bundle (extension + domain/map/config) and deploy to a node:

Terminal window
./helix/runtime/helixctl release --as studio # version from helix/custom/package.json
# on the node (with helixctl + the image) — no credential, no network:
./helix/runtime/helixctl import helix-<client>-studio-<version>.tar
./helix/runtime/helixctl <CLIENT> start demo helix-<client>-studio-<version>.tar
# or: start demo | dev | tuning | spiral | spiralOnly | replicant | standby · or: create <name>

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 itstart dev
tune the domain — edit the JSON, no rebuildstart tuning
try or show the productstart demo (in your repo)
run a public demo instance or a tuning labrelease --as studio, then start demo <artefact>
run it for real, with data that survivescreate <name>, then start replicant
survive losing a nodecreate + start replicant + a third vote
a regional viewer, reporting offload, or a durable copystart standby
drive the optimiser from another program, keeping a live UIstart spiral
use the optimiser as a command-line filterstart 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> start demo # this repo's working tree
    ./helixctl <CLIENT> start demo /path/to/repo/helix # another tree already on this machine
    ./helixctl <CLIENT> start demo helix-<client>-studio-<ver>.tar # restore the repo from the release
    ./helixctl <CLIENT> start tuning helix-<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> start spiral # prints the join line
HELIX_PRIMARY=192.168.0.9:3001 HELIX_PUBLIC_HOST=192.168.0.12 \
./helix/runtime/helixctl <CLIENT> start standby # 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.
  • HELIX_NETWORK=pasta | slirp4netns | host | default chooses the rootless-podman network helper.

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 runYour client arrives asWrites to your repo?So it runs…
helixctl <CLIENT> start devyour helix/ directory, bind-mountedyes — the rebuilt bundle lands in helix/runtime/clientyour working tree, live
helixctl <CLIENT> start tuningthe same mountyes, though it builds nothingyour working tree, live
create · replicant · standby · spiral · spiralOnlyhelix/runtime/client.tar, bind-mounted read-onlynothe bundle last placed there
./spiral · ./spiralOnly — the launch commandsa <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.tarnothat bundle, on the core it was built with
a client imagealready inside the imagenowhatever was baked at release

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/helixctl pull # newest core on your SDK line — see below
./helix/runtime/helixctl import helix-<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 liveshow to treat it
SDK — the contracthelix/custom/package.json, exactpinned; recorded in the bundle manifest; start refuses a mismatched minor
Core image — the enginehelix/runtime/VERSIONsdk-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.

Terminal window
./helix/runtime/helixctl release [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.

--aswhat you hand overhow the node takes itwhen
bundlehelix-<client>-<ver>.tarhelixctl import <tar>, then start <type> <tar>the node already runs the core you want
set (default)one tar: core image + bundle + launcher + pinhelixctl installair-gapped, or a controlled production estate
imageone image, everything baked inhelixctl pullyou deploy from a registry
core-setthe core alone, no clienthelixctl install, then import a bundlea special case — bootstrapping a machine that has no core yet, or an air-gapped estate where the core travels separately

A release set — nothing needed on the far side

Section titled “A release set — nothing needed on the far side”
Terminal window
./helix/runtime/helixctl release 1.4.0
# -> ~/Honeywick/release/helix-<client>-release-1.4.0.tar (core image + bundle + helixctl + VERSION)
tar xf helix-<client>-release-1.4.0.tar && cd helix-<client>-release-1.4.0
./helixctl install # load the image, take the artefacts in
./helixctl <CLIENT> create <name> # then: start replicant (a durable node)
# or, to show it: release --as studio, then start demo helix-<client>-studio-1.4.0.tar

A full client container — one image, nothing to install

Section titled “A full client container — one image, nothing to install”
Terminal window
echo registry.example.com/helix > helix/runtime/CLIENT_REGISTRY # once
./helix/runtime/helixctl release 1.4.0 --as image # 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
./helixctl pull && ./helixctl <CLIENT> create <name> # then: start replicant

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”.

Live start types will not run on a floating tag

Section titled “Live start types will not run on a floating tag”

spiral, spiralOnly, create, replicant and standby refuse 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.

Every container prints what it is at boot, taken from the image itself rather than the tag it was pulled under:

[startHelix] start=replicant mode=replicant env=live joinable=1 client=AA core=0.0.15 bundle=AA-1.4.0 sdk=0.1.6 | mongo:durable(ext4)@…

./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).

To move just the core image to a locked-down host, save it by hand — both tags, so the floating one still resolves after loading:

Terminal window
podman save --format docker-archive -o helixcore-<ver>.tar \
registry.gitlab.com/honeywick-consulting/helix/helixcore:<ver> \
registry.gitlab.com/honeywick-consulting/helix/helixcore:sdk-0.1

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.

Mission Control — the operations console

Section titled “Mission Control — the operations console”

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 -o BatchMode=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> start 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 + 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:

Terminal window
podman run --rm --entrypoint cat <image> /app/THIRD-PARTY-NOTICES.md
  • 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.