Skip to content

Actions

An action is a flag raised against a stop that asks an operator to look at it and make a decision. The system raises the flag, an operator processes it in the context of the job, and the outcome is recorded and audited. Actions are how you put a human checkpoint into an otherwise automatic plan — most usefully, to stop the wrong resource being sent, especially where the cost to serve is high.

(For the screen itself see the Introduction; for how domain configuration fits together see the Helix technical overview.)

The organising idea is the process — a composed piece of operational behaviour such as the standby callout, a rebooking, or keeping a customer informed. A process is made of members of two kinds:

  • a step is a member the system concludes — a notification send and its retries, a roster rewrite, a timeout firing, a boundary force;
  • an action is a member a person concludes — the subset of the work that needs human judgement.

The pool of what needs doing at any moment is the outstanding steps and actions of the live processes. Two rules bind the kinds together. A step that fails becomes an action: when a notification channel exhausts its retries, the process raises a dispatcher action — the manual workaround is built into the process, never bolted on. And the split is a dial, not a design: the same process definition can run a member as an action for one client and a step for another — an ETA update can be a “call the customer” action or an automated message, without redesigning the process.

The built-in processes are catalogued in builtInProcesses. The standby callout is the reference implementation: a trigger step (Spiral raises the call), notify steps (channels ring, retry), an action (accept/refuse), an answer-window step that concludes the action as a refusal if nobody answers in time, and a roster-rewrite step on acceptance.

Spiral optimises the whole operation against your model of cost. Most of the time the plan it builds is the one you want. But the lowest modelled cost is not always the right answer:

  • a long deadhead to reach a job when a nearer resource was almost as good,
  • a premium or scarce resource committed to routine work,
  • a plan that technically satisfies a contract but breaches its spirit,
  • a high-value job about to be served by a resource you would rather hold back.

You cannot economically encode every one of these as a hard constraint — and you would not want to, because the right call often needs a human. An action lets you encode the trigger — “raise a flag when the plan looks like this” — and leave the decision to a controller. The plan keeps moving; the flag waits for a person where a person adds value.

Actions earn their place through improved quality and productivity. The pre-built actions, and the ones you author, group under five themes:

  • Demand reduction — take work out of the plan before a wheel turns: validate entitlement to service at the point of contact, and remote fix — running the Diagnostic Tree with the customer so a visit is avoided entirely. An avoided visit is the cheapest job you will ever serve.
  • Demand smoothing — move work to an easier-to-serve time: a rule spots hard-to-serve bookings (cost or lateness signals) and raises an offer rebooking action; the accepted offer is applied through the rebook machinery.
  • Project verification — review and improve the project definition before it is served: opening and closing times confirmed (the pre-built confirmDestination), parts availability checked, access constraints captured. These are indoor actions: while the engineer works the previous stop, an indoor operator verifies the next one — the productivity gain is leveraging that time so the field is never where a definition gap is discovered.
  • Additional resourcing — bring capacity in when the plan needs it: the built-in standby callout, an authorise overtime checkpoint, or the pre-built outsource action handing work to a third party. All three follow the callout’s shape.
  • Customer management — keep the customer informed and confirm the proposed plan will lead to a successful outcome: the pre-built etaCallback (“call the customer with a revised ETA”), or its automated twin over the notification channels — the action/step dial again.

Pending actions appear in the Actions list in the dispatch screen’s left column. They are ordered with the hottest first — by urgency, then by the stop’s arrival time, latest first — so the most pressing checks sit at the top.

The action-led view (HRA demo): the dispatch panel minimised to the Actions queue and the selected resource's plan, floating over the live map. The queue is ordered hottest-first — each bar's colour is its urgency temperature.

Working the queue is the most efficient way to operate. The overview — every list, every marker — is there when you need it, but it lacks focus: it shows everything, including the large majority of the operation that is running exactly as planned and needs nobody’s attention. The actions queue is the opposite: each entry is a step where a person adds value right now, ranked by how much it matters. Minimise the dispatch panel (the 👁 control in the top bar) and Helix reduces to exactly this — the queue, the plan you are working, and the map — so a controller works decision to decision instead of scanning for them.

  • Click an action and Helix opens the relevant job — its resource, project or response point — in the editor, with the action shown at the top. You always process an action in the context of the root entity, so you can see the plan you are about to change.
  • The action also appears whenever you open that editor yourself on a stop that has one pending — you do not have to come in through the queue to deal with it.
  • Process it by choosing an outcome button (which records your decision), or Defer it (snooze the stop’s actions for a while), or Acknowledge it when it is just a message. While you have an action open it is soft-locked, so it greys out on other controllers’ screens. The lock is a timer, not a claim: walk away without processing and it simply expires (about five minutes) — in the Contact Centre, closing the contact releases it immediately.

An automatic action’s rank doubles as its urgency (the queue temperature); an unranked definition sits at the middle of the scale. Manual messages author their own urgency in the messaging config.

Two settings on each action decide who it reaches:

  • Class — a grouping, drawn from the domain’s catalogue, and the thing that makes an action automatic: the auto-raise engine only ever considers classed definitions. An action with no class is a manual message that anyone can service.

    Class is also the skill gate — the second gate of targeting. actionee and desk scope decide WHERE the work is; class decides WHO among the pooled operators can handle it: each operator’s enabled classes (set in the Administration app, from the domain’s catalogue) filter the dispatch and Contact-Centre queues. An operator with no configuration sees every class, and a class-less action is serviceable by anyone — small teams need set nothing. The rule underneath: class gates pools; individuals are routed by identity — the resource audience (the engineer’s own inbox) ignores class entirely, because that work is already addressed to a person. The filter takes effect at the operator’s next sign-in.

  • Actionee — the audience for the action:

    ActioneeReaches
    anyeveryone
    ownerthe dispatch desk that owns the stop
    viewerany desk owning or watching the stop
    resourcethe resource fulfilling the stop — i.e. the Engineer view

Use owner for decisions that belong to whoever is managing the work (correcting the plan), and resource for things the person doing the job should see.

  • Automatic (classed) — raised by the system from a rule. The backend evaluates the rule every time the plan changes and keeps one automatic action in progress per stop (the highest-ranked one whose rule currently fires). Classed actions are the ones you use to police the plan.
  • Manual (no class) — raised by an operator from the editor. Everyone can see them; nothing is automatic.

Actions are defined per stop type, in the domain file, under helix.actions on the stop’s parameters. Like the rest of stopParams, they are inherited through clone, so a base type can carry an action that all its descendants share. The available class names are listed once, globally, in helix.actionClasses.

// reference helix block — the catalogue of class names
"helix": {
"actionClasses": ["recovery", "resourceBehaviour", "tyres"]
}
// stopParams[type] — actions attached to one stop type
"helix": {
"actions": {
"tyreSource": {
"class": "tyres", // grouping — and what makes it automatic
"actionee": "any",
"rank": 20,
"defer": 300, // a Defer outcome snoozes this stop's actions for 5 min
"text": "sourceTyre()", // expression → message string, or null to not raise
"outcome": ["success", "fail"]
}
}
}

Every field is listed in the actionObject reference; the two worth understanding before you write one are:

  • text is the rule and the message at once. A value ending in () names a function in your client module, called as a method on the stop — this is the stop, so the rule reads this.arv, this.ext and whatever else the plan exposes. Returning a string raises the action with that text; returning null does not raise, and cancels an automatic action already showing. A value without () is literal text. An undefined function simply never raises, so a flag appears only once its rule exists.
  • class is what makes an action automatic. Classed definitions are the only ones the auto-raise engine considers, and only the highest rank whose rule currently fires is kept on a stop. Omit it for a manual message.

messageIn and messageOut use the same action format (actionee, urgency, and a text gate) and live in the stop defaults, so they’re configurable per stop type. Here text is reused as an enable gate: true = always available, an expression = available per stop (e.g. only in certain states), and an absent messageOut hides the facility entirely. messageOut is the dispatch → resource direction — the Message resource button in the stop editor; messageIn is the resource → dispatch direction (the engineer view). By default the resource’s NOW stop is messageable both ways (text: true, actionee resource/owner), and the STBY roster stop carries a messageOut so a standby — above all a called — resource is reachable from dispatch; other stops carry no messageOut, so the message panel stays hidden until you add one. (The resource editor’s message button targets the resource’s head roster stop — NOW when on duty, REST/STBY otherwise — and shows whenever that stop’s config carries a messageOut.)

State-gated actions — the standby callout

Section titled “State-gated actions — the standby callout”

An action definition may carry state: "CALL" (or states: [...]): the action is then eligible only while the stop is in that state, is cancelled automatically the moment the stop leaves it, and — once an outcome is recorded — stays quiet for the rest of that visit, re-arming if the stop re-enters the state later. Because the engine supplies both the gate and the suppression, a gated action needs only literal text — no per-client rule function — so a domain can raise state-triggered checkpoints declaratively.

The standby callout is the worked example, and it is built in: when Spiral calls a standby resource out (the head STBY roster stop goes to state CALL), core synthesises a state-gated action from the roster block’s helix.callout config — raised on call, offered to both the engineer’s inbox and the dispatcher’s queue (actionee: "any"), refuse present only on a voluntary call, and closed with no extra machinery when the call is accepted (the roster rewrite re-derives the stop) or stood down (the state leaves CALL). Choosing accept performs the acceptance itself — the roster rewrite — from either seat.

Worked example: don’t send the wrong resource where cost to serve is high

Section titled “Worked example: don’t send the wrong resource where cost to serve is high”

The goal is to flag a stop before it is committed when the plan would serve it with a resource that costs more than you are willing to pay — so a controller can reassign, or consciously accept it.

// stopParams for a high-value job type
"helix": {
"actions": {
"costToServe": {
"class": "resourceBehaviour", // groups it with the resource-economics checkpoints
"actionee": "owner", // it is the owning desk's decision to correct
"rank": 60, // outranks routine flags, so it wins the slot
"defer": 600,
"label": "Cost to serve",
"icon": "Warning",
"text": "highCostToServe()", // your clientModule rule — below
"outcome": ["reassign", "accept", "escalate"]
}
}
}
// crudClientModule — the client's backend domain functions, loaded at runtime. `this` is the stop being
// scheduled, so the rule reads whatever the plan exposes on it (the planned resource and its modelled cost
// are domain-specific fields).
highCostToServe() {
const planned = this.plannedServeCost; // what the current plan will cost to serve this stop
const target = this.targetServeCost; // the most you are willing to pay for it
if (planned == null || target == null) return null; // can't judge → don't raise
const over = planned - target;
if (over <= 0) return null; // within target → no flag
return `£${Math.round(over)} over target — review the planned resource`;
}

What happens:

  • Each time Spiral re-plans, Helix evaluates highCostToServe() against the stop. While the planned cost is within target it returns null and nothing shows. The moment the plan would overspend, it returns the message and the Cost to serve action is raised on the owning desk.
  • Because it is ranked 60, it takes the stop’s automatic slot ahead of lower-ranked flags (a routine wrongSkill warning at rank 20, say). One stop never nags with two automatic actions at once.
  • The controller clicks it, lands in the job with the plan in front of them, and chooses an outcome — reassign (and correct the plan), accept (record that the spend is justified), or escalate.
  • The outcome is recorded on the stop and audited, which suppresses the flag from coming straight back for the same situation. If they instead Defer, the stop’s actions go quiet for ten minutes while they sort it out off-system.

Tune the threshold in the rule, not the UI — the same pattern covers “resource too far”, “premium vehicle on routine work”, or “holding a resource back”: each is a clientModule function that returns a message when the plan crosses your line, and null when it doesn’t.

Processing an action writes its outcome to the stop (stop.actions.<name>) and into the audit trail, then deletes the action record itself — the stop’s record and the audit are the durable trace; the queue entry is not. (Mechanically the chosen outcome travels as a force of the record’s ext.outcome, then the delete follows — the shape an external system uses to conclude an action programmatically.) A Defer outcome instead timestamps the stop so its actions stay paused until the deferral passes. Either way the plan continues to optimise underneath — actions sit alongside it, they don’t freeze it.

An action can conclude four ways, and only the first needs a person:

  1. An outcome is chosen — by an operator, the engineer, or an external reply through a notification channel; all three converge on the same record-and-delete.
  2. Deferred — hidden until the deferral passes (see the caution below).
  3. Cancelled — the rule stops firing, its state gate closes, the stop goes away, or the definition itself vanishes in a domain reconfiguration; the flag simply retires, nothing is recorded.
  4. Concluded by a step — the callout’s answer window is the built-in example: if nobody answers in time, a step concludes the action as an automated refusal, recorded exactly as if a person had chosen it (the outcome says timeout).

Most new checkpoints need no code. Give the class a name in the catalogue, then declare the action on the stop type it belongs to — a state gate and literal text are usually the whole job:

"actions": {
"remoteFix": {
"class": "technical",
"actionee": "owner",
"rank": 40,
"state": "REBK",
"label": "Check for remote fix",
"text": "Before rebooking, check the fault detail — this may be fixable remotely.",
"outcome": ["fixedRemotely", "visitNeeded"]
}
}

Reach for a text function only when the trigger depends on something a state cannot express — “the plan has put an expensive resource on a cheap job”, “this resource is idle while voluntary work is waiting”. The function returns the message when your condition holds and null when it does not, and it is responsible for staying quiet once an outcome has been recorded.

Two questions settle the design:

  1. Does a person need to judge it? If yes, it is an action. If the system already knows what to do, it is a step — automated, fired by an event, with nobody watching.
  2. Which desk carries it? actionee decides: owner for the desk accountable for the work, viewer for anyone watching it, resource to send it to the engineer in the field, any for both seats.

Actions ask a person; steps act by themselves — events are the timing machinery that fires them. The two kinds work together inside a process: when a notification step exhausts its delivery retries, it gives up and raises an action, so a dead channel is never silent. If you are trying to make something happen automatically on a state change — telling a customer their job is done, for instance — that is a step, not an action.