Skip to main content

Conditions

In the portal: Journeys, then an arrow or an action in the builder.

What it is​

A condition is a rule that comes out true or false about one participant, right now.

There is one rule shape, and it is used in four places.

  • On a transition, to decide whether the arrow is open.
  • On an action, to decide whether that one thing runs.
  • On an atom, to decide whether the participant sees it.
  • On a page of a sequence, to decide whether the page is shown or skipped.

Learn it once and you have learned all four.

What it looks like​

A rule is either a clause, which compares one thing, or a group, which combines other rules.

{ "field": "latestResponse.SLEEP_CHECK.score", "op": "at_least", "value": 10 }
{ "all": [
{ "field": "tags", "op": "contains", "value": "ENROLLED" },
{ "field": "secondsInNode", "op": "at_least", "value": 604800 }
] }

Groups nest inside groups, and clauses sit at the leaves. There is no other shape and no other syntax. No brackets, no AND, no formulas.

When you would use one​

The four places, with a real case for each.

On an arrow. A sleep questionnaire scoring 10 or more sends the participant down a supported track. That is one clause on one transition.

On an action. The same node delivers this week's reading to everybody, and also raises a coach task, but only for participants carrying a risk tag. That is one node with two actions, and a condition on the second one. You do not need a separate node for it.

On an atom. One content card that says "10 minutes, three times a week" to one group and "20 minutes, five times a week" to another. Two atoms on one page, each with its own condition, rather than two cards you then have to keep in step.

On a page. Skip the whole safety follow-up page unless an earlier answer on the same form was above zero.

Put one atom with no condition on every page. The phone decides what to show from data it holds locally, and just after the app starts some of that data may not have loaded yet. An atom with no condition always shows, so the page is never blank.

The operators​

There are ten operators, and this is all of them. They are written in lower case with underscores.

OperatorWhat it comparesTrue when
isanythingThe two are equal. A number and the text of that number count as equal, so 7 matches "7".
is_notanythingThey are not equal, and the field is present.
at_leastnumbersThe field is greater than or equal to the value.
at_mostnumbersThe field is less than or equal to the value.
greater_thannumbersStrictly greater.
less_thannumbersStrictly less.
containslistsThe field is a list and one of its members equals the value.
not_containslistsThe field is a list and no member equals the value, and the field is present.
existsanythingThe field resolves to something. Write no value.
not_existsanythingThe field resolves to nothing. Write no value.

The four number operators need both sides to be numbers, or text that reads as a number. If either side is a word, the clause is false. It never errors.

contains is list membership and nothing else. It is not a text search. A clause asking whether a free-text answer contains the word "tired" is always false, because the answer is text and not a list. Use it for tags, groups, and multi-answer questions. If a branch depends on what somebody typed, ask the question as a choice instead.

Spell the operator exactly. A misspelt operator is not a rule that happens to be false. The phone cannot read it at all, so the atom or the arrow it sits on behaves as though the condition were never met, and the participant sees nothing.

That looks identical on the phone to a rule that is correctly false, which is why the operator and the field path are the two things to check first when a rule is not doing what you expect.

The fields you can read​

Every field is written as a path. Paths are case-sensitive.

Nothing checks a field path for you. The portal accepts whatever you type. A path with a typo in it resolves to nothing, which makes the rule false, and you get no warning. So when a rule is not behaving as you expect, check the spelling and the capitals of the path first. Two retired field names are covered on Schedules and recurrence.

FieldWhat it holds
tagsThe list of tags the participant carries. Use with contains.
groupsThe list of groups they are in. Use with contains.
extras.<extra>One stored value about the participant. If that value holds structured data, each field inside it is also readable as extras.<extra>.<name>.
secondsInNodeSeconds since they arrived at the node they are on. Resets to zero every time they move.
secondsInJourneySeconds since the journey was assigned to them.
newResponse.<sequence>.scoreThe score of the answer they have just submitted.
newResponse.<sequence>.bandIts band label.
newResponse.<sequence>.atWhen it was submitted, as a number. exists on this reads as "they just did it".
newResponse.<sequence>.values.<atom>One answer out of that submission.
latestResponse.<sequence>.*The same four, for the most recent answer on file, from any delivery of that sequence.
newAtomResponse.<atom>A single answer just given on its own, outside a form.
newAtomResponse.<atom>.atWhen that happened.
latestAtomResponse.<atom>The most recent answer to that atom from any source, on its own or inside a form.
latestAtomResponse.<atom>.atWhen that happened.
signal.<measure>.valueThe participant's latest reading for a body measure.
signal.screeningAlert1 when their most recent screening was at or above its alert level, 0 otherwise.

Inside a form, while it is being filled in, one more namespace is available to atom and page conditions: values.<atom> holds the answers given so far on this sitting.

Read mood from a mood check you deliver, as latestResponse.<mood sequence>.score or .band. The editor also lists a field named signal.mood, labelled Latest mood, which is not wired to a mood check, so use the sequence path above instead.

Do not read a value in the same node that sets it. If a node adds a tag and then hands out a card whose condition reads that tag, the card can be worked out before the tag has been stored, and the participant will not see it. Put another step, or a waiting period, between the two.

New against latest is the distinction that matters most. newResponse is the thing in their hand this second. latestResponse is what is on file. An arrow that reacts to a submission reads newResponse. An arrow, an atom or an action that asks "what do we know about this person" reads latestResponse.

Body measures​

signal.<measure>.value is available for steps, heart_rate, resting_heart_rate, hrv, sleep_duration, flights_climbed, body_mass and active_energy, when the participant has connected a device and given permission.

Six composed measures are also published, each on a fixed scale from 0.0 to 1.0 so a threshold means the same thing on every one of them: sleep_debt.value, consecutive_poor_nights.value, activity_withdrawal.value, recovery_readiness.value, stress_index.value and mood_risk.value.

The condition editor lists the six composed measures alongside the raw ones.

*
Today: the six measures work in a condition, and the editor does not list them, so one has to be written in by hand.

Comparisons against a personal baseline are also part of the grammar: signal.<measure>.zRecent and .zLong compare a run of recent readings against a fourteen day and a ninety day baseline, .zLatest compares the single most recent reading against the fourteen day baseline, and each has an abs form for "moved a lot in either direction".

Personal baselines are switched on for a tenancy in its configuration.

*
Today: they are switched on for us, by us. There is no setting for it in the portal.

So ask us to turn baselines on for your tenancy before you build a rule that compares against one. The plain .value readings and screeningAlert do not depend on baselines.

A new participant has no readings yet, so a clause reading signal. is false for them until their device has sent something. Pair any measure arrow with a second arrow that always fires.

How a condition is worked out​

Absence is the rule that decides most surprises​

A field that resolves to nothing makes every clause false, except not_exists.

That includes the ones that read like they should be true. is_not on a field the participant has no value for is false, not true. not_contains on a field they have no value for is false, not true. Absence cannot establish inequality, so neither of those operators takes it as proof.

If you mean "they have not answered this", write not_exists. If you mean "they answered, and it was not X", write is_not. They are different questions and the platform treats them as different questions.

The same catches people on tags. not_contains reads like "they are not tagged X" and does not mean that, because a participant carrying no tags at all makes it false. If some participants may carry no tags, ask both questions together:

{ "any": [
{ "field": "tags", "op": "not_exists" },
{ "field": "tags", "op": "not_contains", "value": "SUPPORTED" }
] }

Rules about something a participant has not done​

This works the other way round from what most people expect. Nothing fires when a participant fails to do something. Instead you check two ordinary fields: whether the answer is there, and how long they have been on the node. So a rule about something that has not happened is built from the answer's absence and a clock, both of which are ordinary readable fields.

{ "all": [
{ "field": "latestAtomResponse.WALK_LOGGED", "op": "not_exists" },
{ "field": "secondsInNode", "op": "at_least", "value": 259200 }
] }

That reads: they have never logged a walk, and they have been sitting on this node for three days. Which is exactly "they have not logged a walk in three days", built from two facts rather than from an event.

Three fields carry the whole idea:

  • latestAtomResponse.<atom> and latestResponse.<sequence> exist or do not, so not_exists is a first-class branch.
  • secondsInNode resets to zero when they arrive, so "since we asked" is measurable without storing anything.
  • latestResponse.<sequence>.at is a number, so "answered, but a long time ago" is a comparison rather than a special case.

The condition editor offers the atom answer fields, newAtomResponse and latestAtomResponse, alongside the sequence ones.

*
Today: the fields work, and the editor does not offer them, so they have to be written in by hand.

The sequence fields, tags, groups, extras and the time fields are all in the editor already.

Groups, and what an empty one means​

all is true when every member is true. any is true when at least one is. none is true when not one of them is.

An empty group is legal and it has a fixed answer:

GroupEmpty result
all with no memberstrue
any with no membersfalse
none with no memberstrue

So delete the group when you delete its last clause. An empty group does not switch itself off. An empty all on an arrow leaves the arrow open for everyone, and an empty any closes it for everyone.

Numbers, text and lists​

Answers are stored as text a great deal of the time, so a value that reads as a number is treated as one. 7 and "7" are equal, and at_least 10 works against a score stored either way.

A multi-answer question gives a list, and contains is how you read it.

A structured answer, such as the summary a breathing exercise returns, has no single value of its own. Its inner fields are readable one by one, as newResponse.<sequence>.values.<atom>.<field>, and the bare path resolves to nothing.

An answer is spent by the first arrow that uses it​

When a participant submits something, that submission is available as newResponse.*. The moment an arrow whose condition read newResponse opens and is taken, the whole newResponse namespace is cleared for the rest of that pass. newAtomResponse is cleared the same way, separately.

One submission therefore moves a participant at most one answer-driven step. This is deliberate. Without it, a single questionnaire would cascade through every answer-driven branch downstream in one go.

It has one large consequence. The node the participant just landed on runs its actions immediately, by which point newResponse has already been spent. So:

An entry action reads the answer on file, not the answer that brought the participant to its node.

Write latestResponse.<sequence>.score in that action rather than newResponse.<sequence>.score. It holds the same number, it is on file rather than in hand, and nothing spends it.

Put the same rule the other way round: arrows read newResponse, actions read latestResponse.

Worked conditions​

They just finished the welcome card.

{ "field": "newResponse.WELCOME.at", "op": "exists" }

They scored 10 or more on the sleep check, and are not already on the supported track.

{ "all": [
{ "field": "newResponse.SLEEP_CHECK.score", "op": "at_least", "value": 10 },
{ "field": "tags", "op": "not_contains", "value": "SUPPORTED" }
] }

A card that only shows to the gentle pace group.

{ "field": "tags", "op": "contains", "value": "PACE_GENTLE" }

Either they asked for more support, or their last screening raised an alert.

{ "any": [
{ "field": "latestResponse.CHECK_IN.values.SUPPORT_ASK", "op": "is", "value": "yes" },
{ "field": "signal.screeningAlert", "op": "at_least", "value": 1 }
] }

Next​

Actions is the other half of a node: the twelve things a journey can actually do when a participant arrives.