Skip to content

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.

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

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

  • 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 an include environment directive). 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 from helixctl osrm <country>, kept in HELIX_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.json in 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:

Terminal window
./helixCore/startHelix <CLIENT> dev # e.g. ./helixCore/startHelix AA dev

This 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: dev starts it by default. Set HELIX_SPA=built to serve the last built bundle instead. A container has no frontend source to run Vite from, so a container’s dev always 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 @client alias) 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=vite

The 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:

knobdecidesdefault
HELIX_SPAbuilt (the bundle) or vite (helixCore’s own dev server, with HMR)vite for dev (bare metal), else built
HELIX_PUBLIC_HOSTthis 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 modesunset → localhost
HELIX_LOG_LEVELoverrides the derived levelderived 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.

  • clientConfig.tsx exports config: ClientConfig — templates (entity defaults via createTemplates), helixOverrides (custom helix.* functions), about, clientLogo, and optional data-extensions / full-control editors.
  • @helix/client-sdk provides 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. stopParams entries carry a helix block that the frontend reads — icons, tag, warning templates, the stop templates (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 to clientModule functions and client helixOverrides, evaluated with this = the stop/resource (StopContext, with typed getInStop()/getOutStop() accessors). {prop|default} placeholders interpolate values. Resolution chains client helixOverrides first, then core clientModule, and a function returning undefined defers 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 define cannot carry per-client config. frontend/vite.config.mjs bakes several __HELIX_*__ values from process.env at build time, and Dockerfile.release builds the SPA client-agnostically — with no client’s config sourced. 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 helixOverrides were registered only if the module was named in HELIX_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-loaded ClientConfig, 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 loaded ClientConfig — never through a define. Defines are for facts about the build (core version, git state). Bare-metal dev will not catch the difference.

  • The backend is the root *.ts in helixCore/ (crud.ts, dispatch.ts, spiral.ts, queue.ts, crudObject.ts, crudHandlers.ts, …).
  • crud spawns spiral.l64 and talks to it over stdin/stdout, persisting to MongoDB; dispatch tails 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.ts core + the client’s crudClientModule.js, merged in crud.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), and stopParams[type].helix.onState[from][to] (stop transitions, Spiral-output path, crudHandlers.ts updateStopTable). Each gets this = entity/stop and a CrudHookInfo first arg; info.now = ctx.now() (wall-clock + Spiral’s timeDelta). null matrix keys mark create/delete edges. Only entity types with a params block (currently resource) 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.
Terminal window
(cd helixCore/frontend && npx tsc --noEmit) # frontend — aim for 0 errors
(cd helixCore && npx tsc --noEmit) # backend — aim for 0 errors

Run 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):

Terminal window
./publishHelixCore.sh <ver> # build + push; HELIX_OFFLINE_TAR=1 to save a local tar instead

Runtime: helixctl and the image run on podman or Docker (podman first when both are present; HELIX_CONTAINER_RUNTIME picks). Building the image is podman-only — publishHelixCore.sh uses podman’s --ignorefile, which docker build has no equivalent for. The saved air-gapped archive is a docker-archive, so helixctl load works under either runtime.

Fetch the launcher from the image (registry-only):

Terminal window
podman run --rm --entrypoint cat <image> /app/helixctl > helixctl && chmod +x helixctl

Container 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:

Terminal window
cd Clients/<CLIENT> # the client repo root (holds helix/ + tests/)
./helix/runtime/helixctl pull
HELIX_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 browser

See Develop a client extension for the full client-side flow.

Deploy to a node:

Terminal window
(cd Clients/<CLIENT> && ./helix/runtime/helixctl release <ver> --as bundle) # -> a bundle tar
helixctl import <bundle.tar> # takes it into the release area
helixctl <CLIENT> start demo | spiral | replicant | standby # or: helixctl <CLIENT> create <name>
  • demo mounts the client repo (read-only) and is throwaway; the cluster start types need HELIX_PUBLIC_HOST (and HELIX_PRIMARY to join rather than to bring back a world that is already here).
  • HELIX_LOG_DIR_HOST=<dir> bind-mounts the container’s /app/logs to the host so logs survive --rm — defaulted for the cluster modes only, and opt-in for dev/tuning/demo/spiral, which keep nothing. Naming it also raises a simulator’s log level to INFO.
  • The launch account’s ~/.ssh is bind-mounted read-only (and its ssh-agent passed 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, with HELIX_PRIMARY for 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.

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”
Terminal window
./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 from helixCore/client-sdk/package.json.
  • The image carries its own identity: /app/VERSION (the true core) and /app/SDK_VERSION (the contract), plus the helix.version / helix.sdk labels. Everything downstream reads these rather than trusting a tag, because a floating tag by definition cannot say which core it is.
  • helixCore/shared/version.ts is stamped automatically — commit it with the release.
  • @helix/client-sdk → GitLab npm registry; bump it when the SDK contract changes. A breaking bump to 0.2.x starts a fresh :sdk-0.2 line and leaves :sdk-0.1 frozen 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:

--asartifactthe node runsthe node recordswho it is for
bundlehelix-<client>-<ver>.tarhelixctl import, then startbundlea node already on the right core
set (default)one tar: core image + bundle + launcher + pinhelixctl installsetair-gapped or controlled production
imageone image, client baked in, pushedhelixctl pullimageregistry-based deployment
core-setthe core alone — no clienthelixctl install, then importcoreOnly, then bundlebootstrapping 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.

Terminal window
cd Clients/<CLIENT>
./helix/runtime/helixctl release <ver> # a set
./helix/runtime/helixctl release <ver> --as image # a full client container

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

Terminal window
# a set
tar 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 demo

Live 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:

Terminal window
cd Demos/<DEMO> && ./helix/runtime/helixctl release <ver> --as image

The 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.0

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

  • Domain-driven: client wording/behaviour lives in <CLIENT>.domain.json (stopParams.helix.*), not the frontend.
  • spiral.l64 is 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 in storage/bare/session-<pid>/{data,mongo,logs}, a container in storage/{data,mongo,logs} with a sub-path each for HELIX_DATA, HELIX_MONGO_LOG_DIR and HELIX_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-By trailer.

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)”
WhatWhere
JDK 21 (Temurin)~/.local/jdk
Android SDK (cmdline-tools 12, platform-tools, build-tools 35, platform android-35, emulator)~/Android/sdk
System imageandroid-35 google_apis_playstore x86_64 — the Play image, required for FCM push testing
AVDhelixField (Pixel 7, API 35)
Environmentsource ~/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:

Terminal window
sudo modprobe kvm_amd # kvm_intel on an Intel box
echo kvm_amd | sudo tee /etc/modules-load.d/kvm.conf
sudo usermod -aG kvm $USER # then log out and back in

If the modprobe fails with Operation not supported, virtualisation (SVM/VT-x) is disabled in the BIOS — enable it there first. Then:

Terminal window
source ~/Android/env.sh
emulator -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.