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; `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)
.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 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.
  • 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 # 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> 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 a container runtime. 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).

On Docker: the dev loop writes the built bundle into your working tree (helix/runtime/client) and its logs into helix/runtime/logs/. 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 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 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),
    • 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, 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.
  • 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.

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

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 simulatorstopParams[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.

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 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.
  • 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 bundle # version from helix/custom/package.json
# on the deployment node (with helixctl + the image):
./helix/runtime/helixctl deploy helix-<client>-<version>.tar
./helix/runtime/helixctl <CLIENT> run # writable single node — see "Which mode?" below
# or: demo | dev | tuning | spiral | spiralOnly | master | replicant | readOnly | snapshot

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.

Start from what you are trying to do, not from the list:

I want to…mode
develop the extension — edit clientConfig.tsx and see itdev
tune the domain — edit the JSON, no rebuildtuning
try or show the productrun
run a public demo instancedemo
run it for real, with data that survivesmaster
survive losing a nodemaster + replicant + a third vote
a read-only regional viewer or reporting offloadreadOnly
drive the optimiser from another program, keeping a live UIspiral
use the optimiser as a command-line filterspiralOnly

Mode and deployment class are different axes. run, demo and dev all report mode solo; the class (dev / demo / live) is what says how seriously to treat the instance. startHelix prints both in its boot line, and Mission Control shows them side by side.

What each one actually is:

  • run — a single self-contained node (solo is the same mode): unique name, tmpfs mongo, one published port, so several can run at once on a host. Not durable — for data that survives, use master.
  • demorun with the demo deployment class. It sets HELIX_DEMO for you, which quiets the auxiliary logs and enables lead capture. The demo orchestrator drives this same word.
  • 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. (Stub: its vault / crud2spiral replay is still pending.)
  • spiral — the fed primary: the full stack, but crud takes its Spiral 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.
  • 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.
  • master / replicant / readOnly / snapshot — the cluster. All need HELIX_PUBLIC_HOST; all but master also need HELIX_PRIMARY. snapshot is a stub: it joins as a hidden non-voting member and runs a read-only stack, but the periodic capture it exists for is not wired — do not rely on it for backup.

A two-node cluster does not fail over. MongoDB elects on a majority of votes, so master + one replicant is two votes: lose the master 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 readOnly (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 master it buys nothing. Note also that member flags are set when a node joins: adding the third node fixes a new cluster, while an existing readOnly deployed before Helix 0.0.3u keeps its non-voting setting until an rs.reconfig — redeploying the container does not change a member already in the replica-set config.

A fed primary is joinable, and that is why it needs an address. spiral initiates the replica set at HELIX_PUBLIC_HOST just as master 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> spiral # prints the join line
HELIX_PRIMARY=192.168.0.9:3001 HELIX_PUBLIC_HOST=192.168.0.12 \
./helix/runtime/helixctl <CLIENT> snapshot # 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 master. 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 or readOnly node runs no optimiser until it is elected — at which point it launches crud, which launches Spiral, on its own hostname. Licence only the master 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_NETWORK=pasta | slirp4netns | host | default chooses the rootless-podman network helper.

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 deploy 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> run|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; deploy 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 bundle|set|image] # version defaults to 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 deploy <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 deploya 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, pin VERSION, deploy the bundle
./helixctl <CLIENT> run # or master / replicant / readOnly

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> run

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

run, spiral, spiralOnly, master, replicant, readOnly and snapshot refuse to start when the image is latest or sdk-*:

helixctl: 'master' 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] family=cluster concurrency=cluster mode=master client=AA core=0.0.5 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 — deployment class and mode (dev · solo, live · master), 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.

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> 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 + deploy 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.