Events
Helix has two ways to make something happen when the operation moves. They are easy to confuse, and picking the wrong one is the usual reason a process does not fire.
- An action asks a person to do something. It lands in the Actions queue for a qualified operator to pick up — rebook this job, check whether the fault could be fixed remotely, look for voluntary work while the resource is still on its previous stop.
- An event does something itself, at a time, with nobody watching. Move the stop to its next state, mark a resource off duty at the shift boundary, send the customer the update you promised them.
If a human has to decide, it is an action. If the system already knows what to do, it is a step — and an event is the timing machinery that fires it. In the process vocabulary, a process is composed of steps and actions; the event queue is how a step’s moment arrives.
The kinds of event
Section titled “The kinds of event”One engine drives the first three, but they do not all run in the same conditions — which matters more than anything else on this page. The fourth runs somewhere else entirely: on the engineer’s own device.
| What it does | Live | Replay | Runs on | |
|---|---|---|---|---|
| The simulator | Walks stops through their lifecycle with no engineer present | No | Yes | The server |
| Roster boundaries | STBY / REST entry and exit at shift edges | Yes | Yes | The server |
| State-change hooks | Runs your code when a stop changes state | Yes | Yes | The server |
| Engineer events | Drives the field device — e.g. opens native navigation | Yes | No | The engineer’s device |
The simulator
Section titled “The simulator”The reason a scenario plays at all. Three built-in events move each stop along — despatched to heading, heading to arrived, arrived to done — timed from Spiral’s own plan with deterministic jitter, so a replay is repeatable. A domain can override any single clause of them; the eventObject reference has the shape.
This is replay only, by design. In live operation the states come from the field, and a simulator would be arguing with reality.
Roster boundaries
Section titled “Roster boundaries”Shift edges are ordinary operational timing, so they run everywhere. The off-duty periods on a resource’s head roster stop schedule themselves from the shift start, and fire whether or not a scenario is running. The standby callout’s answer window is the same machinery: if nobody answers within the contract term, the timeout fires and the call stands down.
State-change hooks — where your processes go
Section titled “State-change hooks — where your processes go”The one to reach for when you want your process to run in a live operation. A stop type declares a matrix of transitions, and each cell names a function in your client module:
"onState": { "ARVD": { "DONE": "tellCustomerComplete()" }, "*": { "REBK": "tellCustomerRebooking()" }}The keys are clientState values — the states your domain displays, not Spiral’s raw ones — and *
matches any. The function runs with this bound to the stop, and receives the transition it fired on.
Engineer events — on the field device
Section titled “Engineer events — on the field device”onState runs on the server. Its twin, engineer, runs on the engineer’s own phone — the
dedicated field entry (/engineer). It fires on the same clientState transitions, but instead of naming
a function in your crud module it names a device command, so heading to a job can open turn-by-turn
navigation by itself:
"helix": { "engineer": { "DPLY": { "HEAD": { "command": "openNativeMaps" } } }}The shape mirrors onState — engineer[fromClientState][toClientState], * matches any, resolution is
most-specific first (exact cell, then the * row, then the * column). When the stop this is declared on
transitions DPLY → HEAD on that engineer’s device, the device opens native maps to the stop’s own
location. Unlike onState, engineer does inherit through clone and DEFAULT, like every other
helix parameter.
Commands are a fixed allowlist — that is the whole safety story. An engineer event can only invoke a method the Helix Field shell already exposes; a name that is not on the list is silently ignored. Today that list is:
command | What the device does | args (optional) |
|---|---|---|
openNativeMaps | Opens the device’s native turn-by-turn to a destination | lat, lng, label — default to the transitioning stop’s own coordinates and title |
Because the surface is fixed, a domain can wire rich device behaviour declaratively without opening any
new native power — the shell just runs openNativeMaps, and the arguments come from the same trusted
helixParameter expressions everything else uses (this is the stop, so a domain with a client module
can compute a label, or override the destination). The zero-argument form above needs no expressions at
all, so it works for a domain-file-only client.
Three things keep it from surprising anyone:
- The field entry only. These run on
/engineerand nowhere else — a dispatcher watching the same resource in the full app never has their maps hijacked. - Live transitions only. The first snapshot after an engineer picks their resource seeds the states silently; nothing fires until a state actually changes after that. Opening the app does not open maps.
- Opt-in. Core ships no default
engineercell. Nothing happens until a domain authors one.
Reacting to a state change
Section titled “Reacting to a state change”The worked example: tell the customer when their job is done.
Core ships the hook and the moment, never a sender. Helix tells you precisely when the operation reached the point you care about; what leaves the building is yours.
1. Say when, in the domain file. On the stop type the customer cares about:
"helix": { "onState": { "ARVD": { "DONE": "tellCustomerComplete()" } }}The keys are clientState values and * matches any. Resolution is most-specific first: the exact cell,
then the * row, then the * column.
2. Say what, in your client crud module. this is the stop, and the hook is handed the transition:
exports.tellCustomerComplete = async function (info) { const to = this.ext?.customerPhone; if (!to) return; // nothing to tell, nothing to do await mySmsGateway.send(to, 'Your job is complete.');};A hook that throws is caught and never blocks the stop from being written — a failed message must not stall the plan. The corollary is that it also fails silently, so anything you need to know about has to be logged or retried by your own code.
Plugging a vendor into Helix’s own notifications
Section titled “Plugging a vendor into Helix’s own notifications”Separately from your own sending, core has a notification queue with retry, delivery tracking and a
token-authed inbound route for replies. It ships console, webhook and smtp adapters, which need no
accounts. A client adds SMS, WhatsApp or voice by exporting a notifyChannels adapter map from its
crud module — core then uses it for the notifications it raises, the standby callout above all.
That queue is currently core’s to use: a client registers channels for it and can override the callout’s
own message hooks, but does not queue its own messages through it. For a process of your own, send from
the onState hook as above.
A delivery that fails is not silent
Section titled “A delivery that fails is not silent”When a notification on core’s queue exhausts its retries, Helix raises an action in the dispatcher’s queue. That is the two mechanisms working together as intended: the event tried to handle it automatically, and when it could not, it asked a person. A message you send yourself from a hook is outside that loop — raise your own signal if it matters.
Extending this for another process
Section titled “Extending this for another process”- Decide which mechanism. Does a person need to judge something? Action. Does the system already know what to do? Event.
- Decide whether it must run live. If yes, it is an
onStatehook or a roster boundary — not a simulator event. - Author the trigger declaratively where you can. A state-gated action needs no code at all; an
onStatecell needs one small function. - Keep the function boring. Read what you need off the stop, do the one thing, return. These run inside the write path for every stop change, so they must be quick, and anything slow or unreliable belongs behind a queue of your own rather than inline.
Related
Section titled “Related”- Actions — the other mechanism, and how operators work the queue
- The engineer’s app — the field device that engineer events drive
- eventObject — the
raise/when/if/thencontract - actionObject — the action schema
- Notifications & Integration — channels, retries, replies
- Scenarios — injections and the Event Log