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 components | the 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 overrides | helixctl — the launcher (baked into the image) |
The platform is client-agnostic: it loads your extension at runtime from a mounted /client bundle and your
domain/map config from /app/<CLIENT>. You never edit helixCore — everything client-specific lives in your repo.
2. Your client repo layout
Section titled “2. Your client repo layout”<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/helixctlis a bootstrap stub, not the launcher. The real, full-featuredhelixctllives 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 withHELIX_HOMEpointed 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 builtclient/bundle,logs/, and.helixctl-cache/are git-ignored. The podman image lives in podman’s store —helixctl pullfetches it.
3. Prerequisites (one-time)
Section titled “3. Prerequisites (one-time)”- On PATH:
podman(or docker). You do not neednodeornpmon 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 loginbelow). The@helix/client-sdknpm registry is anonymously pullable, so installing the SDK needs no token (the repo’s.npmrcscopes@helix/*to it; do not add an_authTokenline). SPIRAL_LICENCEin 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 runsnpm ci/npm installfor you (into a cachednode_modulesvolume) before starting the watch build.
Get the launcher and image
Section titled “Get the launcher and image”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:
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):
podman run --rm --entrypoint cat \ registry.gitlab.com/honeywick-consulting/helix/helixcore:sdk-0.1 /app/helixctl-bootstrap > helix/runtime/helixctlchmod +x helix/runtime/helixctlYou 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/):
./helix/runtime/helixctl <CLIENT> dev # e.g. ./helix/runtime/helixctl AA devThis 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:
- Edit
clientConfig.tsx, a component, or<CLIENT>.domain.json. - Vite rebuilds the bundle in ~1s (watch the
clientbuild.login the host log dir; the first run also logs the one-timenpm installthere). - 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
viteand@helix/client-sdkitself on first run (the SDK registry is anonymously pullable, no token). Nothing else.
5. The extension — clientConfig.tsx
Section titled “5. The extension — clientConfig.tsx”Export a config: ClientConfig from @helix/client-sdk:
templates— entity defaults, built withcreateTemplates({...})(resource/project/desk/responsePoint).helixOverrides— your implementations ofhelix.*functions referenced by the domain file (returnundefinedto 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):
addContractFormoverrides the generic “raise a contract” form, andsearchContractssupplies contract search results (merged with the built-in search over the domainhelix.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’shelix.templatesis the string"component".
- data extensions — a React panel embedded in the standard entity editor
(
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.
6. Domain config drives the UI
Section titled “6. Domain config drives the UI”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 itsset(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.
helix.* parameters
Section titled “helix.* parameters”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 authoredInStop(input attributes —type,option,minLeaveIn,to/and/with/…). Input-only fields such asminLeaveInlive here, not ondata.this.getOutStop()→ the runtimeOutStop(state,dep,arv,fin,tvl,spiralKey, …).this.getRoot()→ the owning entity’s root data, including the clientext— this is how overrides read data entered by a data extension, e.g.this.getRoot().ext.vehicleorthis.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.
7. Backend hooks — crudClientModule.cjs
Section titled “7. Backend hooks — crudClientModule.cjs”Some logic must be authoritative and server-side — validation that rejects a bad edit, a field stamped
once on creation, a side effect on a state change. That belongs in the crud (backend), not the frontend. The
crud loads an optional crudClientModule.cjs from your custom/ dir (a CommonJS module, merged over the
core) and calls its named functions wherever the domain references them. Each is called with this set to
the entity/stop; lifecycle hooks also receive a CrudHookInfo first argument. (The file is .cjs so it stays
CommonJS even though the extension package is "type": "module" for the vite frontend build.)
| Domain reference | Runs on | this | Purpose |
|---|---|---|---|
stopParams[type].helix.check | crud input (add/update) | the stop | return a non-empty string to reject the mutation |
<entity>Params[type].helix.onCreate / .onDelete | crud input, live only | the entity | entity lifecycle (e.g. stamp a field on creation) |
stopParams[type].helix.onState[from][to] | Spiral output (state change) | the stop | react to a stop transition |
- Live only.
onCreate/onDeletefire for genuine live mutations (after scheduling has started) — not the initial bulk load or file replay.onCreateruns before the mongo write, so a field you set onthisis persisted by that same add. - Stop matrix.
onStateis 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/onDeleteon their params block —projectParams,resourceParams,responsePointParamsanddeskParams.projectParams[type].helix.rootalso names the project’s root stop type. (For example, HRA wiresprojectParams[...].helix.onCreate = "stampCreated()"to stampext.createdAton a new project.) CrudHookInfo—{ now, live, verb, entityType?, fromState?, toState? }.nowis 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 followinfo). 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):
/** @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') */ },};8. SDK ↔ image version contract
Section titled “8. SDK ↔ image version contract”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/VERSIONand/app/SDK_VERSION, exposed as thehelix.version/helix.sdklabels). helixctl versionreads 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 deploychecks minor compatibility: a bundle built against0.1.xdeploys on any0.1.xcore, but is refused on a0.2core. When Honeywick releases a breaking contract (0.2), bump your@helix/client-sdkdependency, rebuild, and re-pinhelix/runtime/VERSIONtosdk-0.2.
9. Build & deploy
Section titled “9. Build & deploy”Build the bundle:
(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:
./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 | readOnlyrun= single self-contained node.spiral= read-only optimiser streaming.master/replicant/readOnly= cluster (needHELIX_PUBLIC_HOST; replicant/readOnly needHELIX_PRIMARY).HELIX_LOG_DIR_HOST=<dir>bind-mounts logs to the host.
10. Conventions
Section titled “10. Conventions”- 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 frontendhelixOverridesare 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+deployto ship.