Develop helixCore
A practical guide to developing Helix and its client extensions from a Honeywick workstation. The everyday loop is bare-metal, no container; the container is for testing and shipping deployments.
1. What you’re building
Section titled “1. What you’re building”- helixCore — the client-agnostic platform: a React SPA + a TypeScript backend (
crud,dispatch) that wraps the proprietary Spiral optimiser (spiral.l64), backed by MongoDB. - Client extensions — per-client repos (e.g. AA) that plug into helixCore via the published
@helix/client-sdk: domain/map config + React config/components. helixCore ships without any client baked in. - Spiral — the optimisation engine, built separately.
The split: anything client-specific (wording, behaviour, templates, icons, custom components) lives in the client repo / domain file — never hardcoded in helixCore.
2. Repo layout on a Honeywick box
Section titled “2. Repo layout on a Honeywick box”~/Honeywick/ Helix/ # the helixCore platform repo helixCore/ frontend/ # React SPA (Vite) *.ts # backend: crud, dispatch, spiral, queue, crudObject, … client-sdk/ # @helix/client-sdk source startHelix # bare-metal + container launcher config # env contract (ports, paths, domain/map) release/helixctl # container launcher (host-side) Dockerfile.release # client-agnostic runtime image publishHelixCore.sh # build + push the image / SDK AA -> ../Clients/AA/helix/custom # symlink to the active client's customisation (per client) Clients/<CLIENT>/ # each client's repo (git) helix/ custom/ # client extension SOURCE (= Helix/<CLIENT> via the symlink) <CLIENT>.domain.json # Spiral/domain config (stopParams, resourceParams, helix.* blocks) <CLIENT>.map.json # Spiral map config # client env overrides (sourced by helixCore/config) frontend/clientConfig.tsx# the client extension (templates, helixOverrides, logo, editors) vite.client.config.ts # builds the extension -> ../runtime/client package.json # depends on @helix/client-sdk runtime/ # launcher stub + version pins + built bundle (client/, served as /client) scenarios/ # Helix-only client scenarios tests/ # client tests + results (ROOT — shared with the Spiral-only world) release/ # legacy Honeywick/Spiral artifacts Spiral/ # the Spiral optimiser source (builds spiral.l64)Each client provides Helix/<CLIENT> as a symlink to Clients/<CLIENT>/helix/custom — that’s what startHelix <CLIENT> resolves.
3. Prerequisites (one-time)
Section titled “3. Prerequisites (one-time)”- On PATH:
node,npm,mongod,mongosh. - Environment (typically in
~/.bashrc):SPIRAL_LICENCE— a Spiral command, e.g.{"set":{"licence":"<hex>"}}(not the raw key; Spiral reads this env var via aninclude environmentdirective). The app refuses to start without it.GOOGLE_MAPS_API_KEY— for the map.- Optional:
SPIRAL_JSON,AA_VEHICLE_API_KEY. - Optional, for road routing:
SPIRAL_TRAVEL_CALCULATOR. Your domain file includes it (see travelCalculatorObject), and the data comes fromhelixctl osrm <country>, kept inHELIX_OSRM_DIR(default~/HelixRuntime/osrm). - Optional, to run with a different domain or map file than the client’s:
SPIRAL_DOMAIN/SPIRAL_MAP, the paths to those files. They apply when you start Helix directly on your machine. For a container, helixctl reads them (or a<Client>.domain.jsonin the directory you run it from) and passes the file in.
- Install deps:
(cd helixCore/frontend && npm install)and(cd Clients/<CLIENT>/helix/custom && npm install).
4. The everyday loop — bare-metal dev (no container)
Section titled “4. The everyday loop — bare-metal dev (no container)”From the Helix repo:
./helixCore/startHelix <CLIENT> dev # e.g. ./helixCore/startHelix AA devThis brings up, with dynamic ports (printed) and opens the browser:
- mongod (data under
<repo>/helix/runtime/storage/bare/session-<pid>/data), - dispatch + crud (the backend, wrapping Spiral),
- a Vite dev server with HMR:
devstarts it by default. SetHELIX_SPA=builtto serve the last built bundle instead. A container has no frontend source to run Vite from, so a container’sdevalways serves the bundle.
Live reload covers both sides:
- editing the helixCore frontend (
helixCore/frontend/src/**) hot-reloads, - editing the client extension (
Clients/<CLIENT>/helix/custom/frontend/**, resolved via the@clientalias) hot-reloads too.
Domain/map are read from Clients/<CLIENT>/helix/custom/<CLIENT>.domain.json + .map.json.
EVERY BARE-METAL SESSION TAKES A DIRECTORY OF ITS OWN. It is
<repo>/helix/runtime/storage/bare/session-<pid>/ — or ~/helix/<CLIENT>/storage/bare/session-<pid>/ when
there is no client repository in play — and it holds three sub-directories:
storage/bare/session-<pid>/├── data/ mongod's dbpath (the database used to live INSIDE the log directory)├── mongo/ mongod's own log└── logs/ crud.log, spiral.log, crud2spiral.log, 2crud.log, serve.log, clientbuild.log, and vite.log when HELIX_SPA=viteThe path is on the one [startHelix] line the launcher prints at boot (logs:<path>, beside app:<port>),
so read it from there rather than composing it.
One directory per session, rather than one per mode, is a correctness fix and not a tidy-up. A fixed name meant a mode could not run twice at once, and a shared one meant each new start rotated the previous session’s history away. A start now sweeps the siblings whose pid has gone and keeps the rest: a pid that is gone cannot be using its directory, a live one is somebody’s work, and a reused pid keeps a directory we leave — which is the safe way to be wrong.
There is no dispatch.log in either world, and that is deliberate: dispatch does not redirect its own
output, so bare metal it is on the terminal you launched from and in a container it is the container’s own
log (podman logs). What it says is therefore never in a file — if you need to keep it, redirect the
launch. See Storage and logs.
The other start types (same script, same words as helixctl)
Section titled “The other start types (same script, same words as helixctl)”startHelix <CLIENT> <startType> takes the same vocabulary in either world — demo, dev, tuning,
spiral, create, replicant, multiplexor, standby — so there is nothing to translate between a
laptop and a deployed node, and ./startHelix with no arguments prints the list. A cluster member is given
HELIX_PRIMARY=<host:port> to name the member it joins; without one it must find a world already here.
Three knobs, identical on a host and in a container:
| knob | decides | default |
|---|---|---|
HELIX_SPA | built (the bundle) or vite (helixCore’s own dev server, with HMR) | vite for dev (bare metal), else built |
HELIX_PUBLIC_HOST | this node’s reachable name (an IP works only where the container can reach itself through its published mongo port, which a rootless runtime may not allow) — required for cluster modes | unset → localhost |
HELIX_LOG_LEVEL | overrides the derived level | derived from the start type |
The old flags were deleted in 0.0.16, with no alias: --dev, --solo, --spiral, --init,
--restart, --reload, --replicate, --multiplexor, --standby. They were a second and a third start
vocabulary in one file, two of them on words that had already been retired from the product.
./startHelix AA --dev → ./startHelix AA dev./startHelix AA --solo → HELIX_PUBLIC_HOST=$(hostname) ./startHelix AA dev./startHelix AA --spiral → ./startHelix AA spiral./startHelix AA --replicate <url> → HELIX_PRIMARY=<host:port> ./startHelix AA replicant./startHelix AA --init|--restart|--reload → ./startHelix AA replicant (it initiates its own set)--solo and --dev were one start type and two knobs all along: they differed in reachability and in
nothing else, which HELIX_PUBLIC_HOST already says.
This is the primary Honeywick workflow — every start type is testable here with no container.
5. The client-extension model
Section titled “5. The client-extension model”clientConfig.tsxexportsconfig: ClientConfig—templates(entity defaults viacreateTemplates),helixOverrides(customhelix.*functions),about,clientLogo, and optional data-extensions / full-control editors.@helix/client-sdkprovides the types + helpers (ClientConfig,createTemplates,StopState,colors, …). In Honeywick dev it resolves to the helixCore source via a Vite alias; a standalone client build resolves the published package from the GitLab npm registry.- Domain config drives the UI.
stopParamsentries carry ahelixblock that the frontend reads — icons,tag, warning templates, the stoptemplates(option generator), add/morph-stop menus, state colours, etc. Change behaviour/wording there, not in the frontend. helix.*parameters are string expressions (e.g."helixColorDelay()") bound toclientModulefunctions and clienthelixOverrides, evaluated withthis= the stop/resource (StopContext, with typedgetInStop()/getOutStop()accessors).{prop|default}placeholders interpolate values. Resolution chains clienthelixOverridesfirst, then coreclientModule, and a function returningundefineddefers to the previous override — so overrides can handle only the cases they care about.- Icons live in
helixCore/frontend/public/icons/*.png(core) plus client icons, named via the icon registry (icons.tsx).
Trip hazard: a Vite
definecannot carry per-client config.frontend/vite.config.mjsbakes several__HELIX_*__values fromprocess.envat build time, andDockerfile.releasebuilds the SPA client-agnostically — with no client’sconfigsourced. So any define fed by a client-supplied env var is empty in every released image, and the behaviour it gates silently never happens in a container, while working perfectly in bare-metal dev (which does source the client config before Vite runs).This bit us once already: client
helixOverrideswere registered only if the module was named inHELIX_CLIENT_MODULES, so no client override resolved in any containerised deployment — the fault surfaced as wrong colours on unresourced stops, because those states were the only ones whose value came from an override. The fix was to stop asking a build-time define a runtime question: the overrides arrive as a field of the runtime-loadedClientConfig, so they are now loaded whenever they are present.The rule: if a value can differ per client, it must reach the frontend at runtime — via
/api/config, the reference/domain, or the loadedClientConfig— never through adefine. Defines are for facts about the build (core version, git state). Bare-metal dev will not catch the difference.
6. Backend development
Section titled “6. Backend development”- The backend is the root
*.tsinhelixCore/(crud.ts,dispatch.ts,spiral.ts,queue.ts,crudObject.ts,crudHandlers.ts, …). crudspawnsspiral.l64and talks to it over stdin/stdout, persisting to MongoDB;dispatchtails Mongo change streams and broadcasts to the SPA over WebSockets.- Tags correlate a command with Spiral’s per-command “done” echo (used for REST replies, progress, and run-file completion). Input and output run on two independent async queues so command delivery isn’t throttled by output processing.
- Client backend hooks (
crudClientModule.tscore + the client’scrudClientModule.js, merged incrud.ts): the crud calls module functions named by the domain —stopParams[type].helix.check(validation, input path,crudObject.ts),resourceParams[type].helix.onCreate/onDelete(entity lifecycle, input path, live mutations only), andstopParams[type].helix.onState[from][to](stop transitions, Spiral-output path,crudHandlers.tsupdateStopTable). Each getsthis= entity/stop and aCrudHookInfofirst arg;info.now=ctx.now()(wall-clock + Spiral’stimeDelta).nullmatrix keys mark create/delete edges. Only entity types with a params block (currentlyresource) can carry entity hooks. The contract types are exported from@helix/client-sdk. - Functional tests are run files:
Clients/<CLIENT>/tests/regression/*.run.json(repo root; shared with the Spiral-only world; replayed through the CRUD pipeline). There is no unit-test framework.
7. Type-check & quality gate
Section titled “7. Type-check & quality gate”(cd helixCore/frontend && npx tsc --noEmit) # frontend — aim for 0 errors(cd helixCore && npx tsc --noEmit) # backend — aim for 0 errorsRun both before committing. The regression .run.json files are the functional safety net.
8. Container development & deployment (helixctl)
Section titled “8. Container development & deployment (helixctl)”The container is for deployment (and testing it) — not the daily loop. The launcher helixctl is
client-agnostic; the client name is always explicit (mirrors startHelix <CLIENT>).
Build/refresh the image locally (when testing the container path):
./publishHelixCore.sh <ver> # build + push; HELIX_OFFLINE_TAR=1 to save a local tar insteadRuntime:
helixctland the image run on podman or Docker (podman first when both are present;HELIX_CONTAINER_RUNTIMEpicks). Building the image is podman-only —publishHelixCore.shuses podman’s--ignorefile, whichdocker buildhas no equivalent for. The saved air-gapped archive is a docker-archive, sohelixctl loadworks under either runtime.
Fetch the launcher from the image (registry-only):
podman run --rm --entrypoint cat <image> /app/helixctl > helixctl && chmod +x helixctlContainer client dev (live rebuild, browser refresh — no HMR). To develop a client extension the way a
client does (no helixCore source, no bare metal), use the launcher that ships in the client repo’s
helix/runtime/ and run from that repo’s root:
cd Clients/<CLIENT> # the client repo root (holds helix/ + tests/)./helix/runtime/helixctl pullHELIX_LOG_DIR_HOST=$PWD/logs ./helix/runtime/helixctl <CLIENT> start dev # mounts helix/ (+ root tests/), `vite build --watch`# edit client source -> ~1s rebuild (logs/clientbuild.log) -> refresh the browserSee Develop a client extension for the full client-side flow.
Deploy to a node:
(cd Clients/<CLIENT> && ./helix/runtime/helixctl release <ver> --as bundle) # -> a bundle tarhelixctl import <bundle.tar> # takes it into the release areahelixctl <CLIENT> start demo | spiral | replicant | standby # or: helixctl <CLIENT> create <name>demomounts the client repo (read-only) and is throwaway; the cluster start types needHELIX_PUBLIC_HOST(andHELIX_PRIMARYto join rather than to bring back a world that is already here).HELIX_LOG_DIR_HOST=<dir>bind-mounts the container’s/app/logsto the host so logs survive--rm— defaulted for the cluster modes only, and opt-in fordev/tuning/demo/spiral, which keep nothing. Naming it also raises a simulator’s log level toINFO.- The launch account’s
~/.sshis bind-mounted read-only (and itsssh-agentpassed through) so the node can ask another machine whether it could host one.HELIX_SSH_DIR_HOST=<dir>names a different directory;HELIX_SSH_DIR_HOST=lends nothing.
9. Testing client modes from Honeywick (no registry needed)
Section titled “9. Testing client modes from Honeywick (no registry needed)”- Bare-metal already exercises every mode:
startHelix <CLIENT> dev | demo | tuning | spiral | replicant | multiplexor | standby, withHELIX_PRIMARYfor a member that joins. Start here. - Local container loop to validate the deployed path: build the image locally
(
HELIX_OFFLINE_TAR=1 ./publishHelixCore.sh <ver>), build the client bundle, then point helixctl at the local image (HELIX_IMAGE=<local-ref> helixctl <CLIENT> start dev …). No publish required.
10. Release flow
Section titled “10. Release flow”There are two release models, and they meet at the pin. The pin itself — floating versus exact, what a node reports, and how to upgrade one or a fleet — is set out in Versions and upgrades; this section is the release side of it. The core release publishes an engine; a client (or demo) release ships a tested pairing of that engine with an extension.
10.1 The core release model — publishHelixCore.sh
Section titled “10.1 The core release model — publishHelixCore.sh”./publishHelixCore.sh <ver> # push (the default when neither flag is given)./publishHelixCore.sh <ver> --set # cut an air-gapped core set instead, no push./publishHelixCore.sh <ver> --push --set # both — the registry AND the airgap in one run--push and --set are independent outputs, not alternatives, which is the point of the pair: a release
commonly wants both. --set cuts helixcore-release-<ver>.tar by calling
helixctl release --as core-set — one assembler for every release set, living in the launcher rather than
duplicated here. The far side installs it with helixctl install (load + pin; no bundle, so no deploy step).
HELIX_OFFLINE_TAR=1 still means exactly what it always did — --set and no push — because an offline build
box sets it precisely because it cannot reach a registry.
- Three tags per build:
:<ver>,:latest, and the floating:sdk-<MAJOR.MINOR>(e.g.:sdk-0.1) computed fromhelixCore/client-sdk/package.json. - The image carries its own identity:
/app/VERSION(the true core) and/app/SDK_VERSION(the contract), plus thehelix.version/helix.sdklabels. Everything downstream reads these rather than trusting a tag, because a floating tag by definition cannot say which core it is. helixCore/shared/version.tsis stamped automatically — commit it with the release.@helix/client-sdk→ GitLab npm registry; bump it when the SDK contract changes. A breaking bump to0.2.xstarts a fresh:sdk-0.2line and leaves:sdk-0.1frozen at the last 0.1 core.
:sdk-0.1 is what a client repo develops against: helixctl pull then fetches the newest core on that SDK
line without touching the extension. It is not what a client releases on — see below.
10.2 The client release model — helixctl release
Section titled “10.2 The client release model — helixctl release”One command, three delivery forms — plus core-set, which is not a release of a client at all. They start
from the same two facts — an exact core, and a bundle built by that very core inside a container — so they
differ only in how the result travels:
--as | artifact | the node runs | the node records | who it is for |
|---|---|---|---|---|
bundle | helix-<client>-<ver>.tar | helixctl import, then start | bundle | a node already on the right core |
set (default) | one tar: core image + bundle + launcher + pin | helixctl install | set | air-gapped or controlled production |
image | one image, client baked in, pushed | helixctl pull | image | registry-based deployment |
core-set | the core alone — no client | helixctl install, then import | coreOnly, then bundle | bootstrapping a machine with no core, or an air-gapped estate (this is what publishHelixCore.sh --set cuts) |
The form is stamped into the artifact, not asserted by the installer. __bundle writes it into the
bundle’s own manifest.json and --as image writes a helix.delivery label, so the node that receives it
inherits the declaration and its manifest.json records what it was actually built from. That is the fourth
column, and it is what tells you how a node can be cloned — a set node holds the core archive and can seed a
machine that reaches no registry, where a bundle node tells the target to pull. coreOnly is the one value
no release produces: it is a machine holding a core and no client, which a later deploy completes.
cd Clients/<CLIENT>./helix/runtime/helixctl release <ver> # a set./helix/runtime/helixctl release <ver> --as image # a full client containerrelease resolves the current pin to an exact core, builds the extension in a container of that image
(helixctl __bundle — the launcher is baked in, so there is nothing to mirror into client repos), and
refuses if the bundle’s stamped coreVersion is not the core being shipped.
The host needs no node, npm or client toolchain — only the container runtime it already needed. That is
what helixctl check has always claimed for client development, and until 0.0.5 the release step quietly
broke it: publishHelixClient.sh ran npm install on the host, and lived as a hand-synced copy in every
client repo. It is gone; --as bundle is what it used to produce.
A client image is the exact core with the sealed bundle baked at /client.tar, labelled
helix.client / helix.client.version / helix.client.sdk, inheriting the core’s own helix.version and
/app/VERSION. It publishes to <CLIENT_REGISTRY>/<client>:<ver> (a CLIENT_REGISTRY file beside
helix/runtime/VERSION, or --image-ref), and pushes by default (--no-push holds it back). helixctl reads
that label at launch: with a baked client it mounts nothing, and it refuses if the node also has a
deployed bundle, or if the image is baked for a different client — one node, one answer.
10.3 Consuming a release
Section titled “10.3 Consuming a release”# a settar xf helix-<client>-release-<ver>.tar && cd helix-<client>-release-<ver>./helixctl install # load the image, pin VERSION, deploy the bundle./helixctl <CLIENT> start demo
# an image — REGISTRY/VERSION point at the client image; there is nothing else to install./helixctl pull && ./helixctl <CLIENT> start demoLive start types refuse a floating pin. spiral, spiralOnly, create, replicant and
standby will not start on latest or sdk-*, because such a node cannot say which core it is running.
helixctl pin resolves the float and writes the exact tag. dev, tuning and demo are exempt by design —
they mount the full repo and switching core is part of the job.
Every container states its own identity at boot, from the image 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 pairing from the host side, and a start that selects an artefact pins
the core that artefact names, so the two cannot disagree — a version is a pair, and naming one file names both
halves. SDK compatibility is still the gate: a 0.1.x bundle is refused on a 0.2 core.
10.4 Demo releases — the 100% container model
Section titled “10.4 Demo releases — the 100% container model”Each demo (Demos/BlueLight, Demos/HRA, Demos/HRM) is a standalone client-style repo, released exactly
like a client and, on the demo server, always --as image:
cd Demos/<DEMO> && ./helix/runtime/helixctl release <ver> --as imageThe demo server deploys nothing, unpacks nothing and mounts nothing. Which demos it serves, and at which
version, is one line in /srv/helix-demo/secrets.env:
DEMO_SANDBOXES=breakdown:HRA@1.4.0,bluelight:BlueLight@0.9.2,rail:HRM@0.1.0That line is the upgrade control. Change a version, systemctl restart helix-demo, and the next visitor
gets the new build while sessions already running finish on the old image — a rolling upgrade for free,
because a session container is per-visitor and ephemeral. Two demos may sit on different cores.
curl /versions on the orchestrator reports exactly what is being served. See
demoOrchestrator/README.md for the server layout, the
Cloudflare tunnel and the session lifecycle.
11. Conventions
Section titled “11. Conventions”- Domain-driven: client wording/behaviour lives in
<CLIENT>.domain.json(stopParams.helix.*), not the frontend. spiral.l64is a built artifact (from~/Honeywick/Spiral) — it is not maintained/committed from the Helix repo.- Storage: everything a node holds sits under one
storage/directory — bare metal instorage/bare/session-<pid>/{data,mongo,logs}, a container instorage/{data,mongo,logs}with a sub-path each forHELIX_DATA,HELIX_MONGO_LOG_DIRandHELIX_LOG_DIR_HOST. One ignore rule covers the lot, deliberately. See Storage and logs. - Repos are separate: Helix (platform) and each
Clients/<CLIENT>are independent git repos — commit each on its own; don’t cross-commit. - Commit only when asked; finish commit messages with the agreed
Co-Authored-Bytrailer.
12. Mobile shell development (0.0.12 — “Helix Field”)
Section titled “12. Mobile shell development (0.0.12 — “Helix Field”)”The mobile app is a generic native shell hosting a WebView; the full requirements are in the Helix
repo at docs/mobile-shell-requirements.md. The shell is Swift (iOS) + Kotlin (Android), and the
Android leg develops on a Honeywick Linux box. Everything below installs in user space — no root.
The Android toolchain (installed convention)
Section titled “The Android toolchain (installed convention)”| What | Where |
|---|---|
| JDK 21 (Temurin) | ~/.local/jdk |
| Android SDK (cmdline-tools 12, platform-tools, build-tools 35, platform android-35, emulator) | ~/Android/sdk |
| System image | android-35 google_apis_playstore x86_64 — the Play image, required for FCM push testing |
| AVD | helixField (Pixel 7, API 35) |
| Environment | source ~/Android/env.sh — sets JAVA_HOME, ANDROID_HOME, PATH; deliberately not in .bashrc |
To reproduce on a fresh box: download the Temurin 21 tarball into ~/.local/jdk and Google’s
commandlinetools-linux-*.zip into ~/Android/sdk/cmdline-tools/latest, accept licences
(yes | sdkmanager --licenses), then
sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0" "emulator" "system-images;android-35;google_apis_playstore;x86_64" and
avdmanager create avd --name helixField --package "system-images;android-35;google_apis_playstore;x86_64" --device pixel_7.
About 3.5 GB all told.
KVM — the one root step, and it is not optional
Section titled “KVM — the one root step, and it is not optional”The emulator refuses x86_64 images without hardware acceleration. Check with emulator -accel-check;
if it reports /dev/kvm is not found:
sudo modprobe kvm_amd # kvm_intel on an Intel boxecho kvm_amd | sudo tee /etc/modules-load.d/kvm.confsudo usermod -aG kvm $USER # then log out and back inIf the modprobe fails with Operation not supported, virtualisation (SVM/VT-x) is disabled in the
BIOS — enable it there first. Then:
source ~/Android/env.shemulator -avd helixField # scripted GPS routes: Extended controls → Location (GPX/KML)The limits to know before relying on the emulator
Section titled “The limits to know before relying on the emulator”Emulators cover the WebView, cookies, sign-in, the bridge, and scripted GPS routes — ideal for deterministic location-gate logic. They cannot provide real accuracy metadata (gate thresholds are tuned on real hardware only), the battery measurement, or APNs push (FCM works on Play-image emulators; APNs needs a physical iPhone). And iOS cannot be built on Linux at all — the iOS leg needs a Mac with Xcode, which is one reason shell development is Android-first.
Optional IDE: sudo snap install android-studio --classic — the CLI toolchain above works with or
without it.