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

<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
<CLIENT>.ui.json # UI params
config # client env overrides (ports, client modules)
package.json # depends on @helix/client-sdk
.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")
publish-<client>.sh # builds + packs a deployable bundle tar
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/ # the built bundle (clientConfig.js [+ icons]) — served as /client (git-ignored)
.helixctl-cache/ # the extracted real launcher, keyed by image id (git-ignored)
logs/dev/ # per-run logs (git-ignored)
scenarios/ # Helix-only client scenarios (mounted in dev via the helix/ mount)
tests/ # client tests + results — at the ROOT, shared with the Spiral-only world
release/ # legacy Honeywick/Spiral artifacts (untouched)

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 podman image lives in podman’s store — helixctl pull fetches it.

  • On PATH: podman (or docker). You do not need node or npm on the host — the container installs your extension’s deps and builds the bundle itself.
  • Registry access — log in to the container registry to pull the image (see helixctl login below). The @helix/client-sdk npm registry is anonymously pullable, so installing the SDK needs no token (the repo’s .npmrc scopes @helix/* to it; do not add an _authToken line).
  • SPIRAL_LICENCE in your env — a Spiral command, e.g. {"set":{"licence":"<hex>"}} (not the raw key; Spiral reads this env var). Optional: GOOGLE_MAPS_API_KEY, AA_VEHICLE_API_KEY.
  • No host install step: on the first helixctl <CLIENT> dev, the container runs npm ci/npm install for you (into a cached node_modules volume) before starting the watch build.

The repo ships the launcher at helix/runtime/helixctl, pinned to a compatible image via helix/runtime/VERSION. That file holds the floating SDK tag for the SDK line you build against — e.g. sdk-0.1not 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
./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> dev

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

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

Terminal window
./helix/runtime/helixctl <CLIENT> dev # e.g. ./helix/runtime/helixctl AA dev

This runs the helixCore container as a single node, mounts helix/ at /client-src (so config comes from custom/, the served bundle is runtime/client/, and scenarios/ rides along), additionally mounts the root tests/ (shared with the Spiral-only world), and runs vite build --watch inside it. On the first run the container installs your extension’s deps (npm ci/npm install) into a cached node_modules volume — so the host needs no node/npm, just podman. It prints a URL. The helix/ dir is found automatically as the parent of helix/runtime/; pass an explicit path only for a non-standard layout (… dev /path/to/helix).

The loop:

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

Logs are bind-mounted to the host (so they survive the container) under the dir helixctl prints (…/logs/dev/): clientbuild.log, crud.log, spiral.log, dispatch.log, serve.log.

Requires only the helixCore image pulled — the container installs vite and @helix/client-sdk itself on first run (the SDK registry is anonymously pullable, no token). Nothing else.

Export a config: ClientConfig from @helix/client-sdk:

  • templates — entity defaults, built with createTemplates({...}) (resource/project/desk/responsePoint).
  • helixOverrides — your implementations of helix.* functions referenced by the domain file (return undefined to defer to the previous override / the default — see §6).
  • about — client name/version (shown in the About dialog; 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),
    • 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.

Most client behaviour is data, authored in <CLIENT>.domain.json (and .map.json / .ui.json) — not code. On a stopParams (or resourceParams) entry, the helix block configures the frontend:

  • icon, lineLetter, 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.

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 functions become resolvable once the override module is named in config (HELIX_CLIENT_MODULES, e.g. honeywickHelix). 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.

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.
  • plus this.parent(), this.getTime(), this.next(), this.after(), this.resource().

StopContext, InStop and OutStop are exported from @helix/client-sdk.

Override chaining. A name resolves through the loaded helix modules — your helixOverrides first, then the core clientModule. A function that returns undefined defers to the previous override, so you can handle only the cases you care about and let the default behaviour apply for the rest. For example, a colour function that returns 'blue' for project (non-to) stops in their first two minutes and undefined otherwise leaves every other stop on its default colour. Only when no module defines the name at all is the literal string used as-is.

Core icons ship in the image; client icons are bundled from your extension and resolved by name.

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') */ },
};

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 your client’s SDK (from the deployed bundle’s manifest).
  • helixctl deploy checks minor compatibility: a bundle built against 0.1.x deploys on any 0.1.x core, but is refused on a 0.2 core. When Honeywick releases a breaking contract (0.2), bump your @helix/client-sdk dependency, rebuild, and re-pin helix/runtime/VERSION to sdk-0.2.

Build the bundle:

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

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

Terminal window
./helix/custom/publish-<client>.sh <version> # -> helix-<client>-<version>.tar (client/ + helix-config/)
# on the deployment node (with helixctl + the image):
./helix/runtime/helixctl deploy helix-<client>-<version>.tar
./helix/runtime/helixctl <CLIENT> run # writable single node
# or: ./helix/runtime/helixctl <CLIENT> spiral | master | replicant | readOnly
  • run = single self-contained node.
  • spiral = read-only optimiser streaming.
  • master / replicant / readOnly = cluster (need HELIX_PUBLIC_HOST; replicant/readOnly need HELIX_PRIMARY).
  • HELIX_LOG_DIR_HOST=<dir> bind-mounts logs to the host.
  • Domain-driven: prefer <CLIENT>.domain.json (stopParams.helix.*) over frontend code for wording and behaviour.
  • Server-side truth: validation, lifecycle and state-change side effects belong in crudClientModule.cjs (the crud), not the frontend — the frontend helixOverrides are for display only.
  • SDK only: depend on @helix/client-sdk; never import helixCore internals.
  • Keep the SDK version in step with the image you deploy against (helixctl version / the deploy check).
  • Your repo is independent of helixCore — version, branch, and release it on its own cadence.
  • Rebuild the bundle (build:client) after extension changes; re-publish + deploy to ship.