Skip to content

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.

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 doesLiveReplayRuns on
The simulatorWalks stops through their lifecycle with no engineer presentNoYesThe server
Roster boundariesSTBY / REST entry and exit at shift edgesYesYesThe server
State-change hooksRuns your code when a stop changes stateYesYesThe server
Engineer eventsDrives the field device — e.g. opens native navigationYesNoThe engineer’s device

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.

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.

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:

commandWhat the device doesargs (optional)
openNativeMapsOpens the device’s native turn-by-turn to a destinationlat, 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 /engineer and 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 engineer cell. Nothing happens until a domain authors one.

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.

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.

  1. Decide which mechanism. Does a person need to judge something? Action. Does the system already know what to do? Event.
  2. Decide whether it must run live. If yes, it is an onState hook or a roster boundary — not a simulator event.
  3. Author the trigger declaratively where you can. A state-gated action needs no code at all; an onState cell needs one small function.
  4. 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.