Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.coreflux.org/llms.txt

Use this file to discover all available pages before exploring further.

Building a Library of Reusable Logic

Any Action without a trigger clause is a callable — a reusable function with typed inputs and outputs. Build a small library of utility callables for the calculations and transformations your project does repeatedly, then call them from your trigger-based Actions.
Like saving a custom button on a calculator. You don’t rebuild the multiplication function every time you need to multiply — you press the button. Callables are your custom buttons, named after exactly what they do.

When to Reach for This

The moment you write the same expression a second time, or any time a logic block deserves its own name to make the parent Action readable. Also useful when several Actions need to share a calculation that might evolve over time — fix it in the callable, and every caller is updated.

The Pattern

A callable has three parts: declared inputs, the logic that runs, and declared outputs to return:
DEFINE ACTION CalculatePercentage
INPUT value AS DOUBLE
INPUT total AS DOUBLE
DO
    SET "pct" WITH ({value} / {total} * 100)
RETURN
    OUTPUT pct
Supported INPUT types are STRING, INT, DOUBLE, BOOL, and JSON. A callable can return one or many OUTPUT values — the caller names the return variables in the order they’re declared. Here’s a callable that returns three values from one calculation:
DEFINE ACTION AnalyzeReading
INPUT current AS DOUBLE
INPUT threshold AS DOUBLE
DO
    SET "is_above" WITH ({current} > {threshold})
    SET "difference" WITH ({current} - {threshold})
    SET "percent_of_max" WITH ({current} / {threshold} * 100)
RETURN
    OUTPUT is_above
    OUTPUT difference
    OUTPUT percent_of_max
Now a regular trigger-based Action can use both callables instead of duplicating the math itself. Whether you’re tracking water tanks on a farm, fuel storage at a depot, or chemical reservoirs at a plant — the logic is the same:
DEFINE ACTION ProcessTankLevel
ON TOPIC "tanks/+/level" DO
    SET "tank_id" WITH TOPIC POSITION 2
    SET "level" WITH PAYLOAD AS DOUBLE
    SET "capacity" WITH (GET TOPIC "config/" + {tank_id} + "/capacity" AS DOUBLE)

    CALL ACTION CalculatePercentage
        WITH value = {level}, total = {capacity}
        RETURN fill_pct

    CALL ACTION AnalyzeReading
        WITH current = {level}, threshold = ({capacity} * 0.9)
        RETURN above, diff, pct_of_max

    PUBLISH TOPIC "processed/" + {tank_id} + "/fill_percent" WITH {fill_pct}
    PUBLISH TOPIC "processed/" + {tank_id} + "/over_threshold" WITH {above}
The main Action stays focused on what to do (read level, publish results); the callables hold how to calculate. If the percentage formula changes, you fix it in one place.

One Action, One Job

Each Action should do one thing well. Break complex logic into callable Actions with INPUT/OUTPUT:
Separate reusable logic into callable Actions, then compose them in a main Action:
DEFINE ACTION ConvertCelsiusToFahrenheit
INPUT celsius AS DOUBLE
DO
    SET "fahrenheit" WITH ({celsius} * 9 / 5 + 32)
RETURN
    OUTPUT fahrenheit

DEFINE ACTION ProcessTemperatureReading
ON TOPIC "sensors/+/celsius" DO
    SET "sensor_id" WITH TOPIC POSITION 2
    SET "temp_c" WITH PAYLOAD AS DOUBLE

    CALL ACTION ConvertCelsiusToFahrenheit
        WITH celsius = {temp_c}
        RETURN temp_f

    PUBLISH TOPIC "sensors/" + {sensor_id} + "/fahrenheit" WITH {temp_f}

Internal State: KEEP vs PUBLISH

Use KEEP TOPIC for internal persistent state and PUBLISH TOPIC for external broadcast. Never use PUBLISH for values only your system reads:
DEFINE ACTION PersistentCounter
ON EVERY 10 SECONDS DO
    SET "current" WITH (GET TOPIC "state/counter")
    IF {current} == EMPTY THEN
        KEEP TOPIC "state/counter" WITH 1
    ELSE
        KEEP TOPIC "state/counter" WITH ({current} + 1)
    PUBLISH TOPIC "stats/count" WITH (GET TOPIC "state/counter")
OperationUse When
PUBLISH TOPICOther systems or subscribers need to see the value
KEEP TOPICOnly your own Actions read the value (internal state, caches, counters)

One Action for Many Devices

The moment you find yourself about to copy-paste an Action and change one device ID, stop. Use a wildcard — the MQTT character + matches exactly one topic level, so sensors/+/power matches sensors/inv-01/power, sensors/inv-47/power, and any other topic with that shape. The broker fires your Action once per matching message, and TOPIC POSITION lets you read which device the message came from.

Wildcard guidelines

WildcardMeaningWhere to Use
+Matches exactly one levelAction triggers, Model topics, Route mappings
#Matches one or more levelsRoute mappings, Rule topic patterns, broad subscriptions
Use + in Actions and Models for precise instance matching. Use # in Routes and Rules for broad coverage.
Like writing a form letter that says “Dear customer.” One letter, delivered to thousands, and each recipient feels it’s addressed to them.

When to Reach for This

The moment you have two or more devices that should be processed the same way. Wildcards are also the foundation that makes Models that publish for every device work — and they let new devices join your system without any code changes.

The Pattern

Imagine a solar park with a hundred inverters, each publishing power readings. One Action handles all of them:
DEFINE ACTION ProcessInverterPower
ON TOPIC "inverters/+/power" DO
    SET "inverter_id" WITH TOPIC POSITION 2
    SET "power_w" WITH PAYLOAD AS DOUBLE
    SET "power_kw" WITH ({power_w} / 1000)

    PUBLISH TOPIC "processed/" + {inverter_id} + "/power_kw" WITH {power_kw}
    KEEP TOPIC "cache/" + {inverter_id} + "/last_seen" WITH TIMESTAMP "UTC"
On KEEP TOPIC in this example. The last-seen line uses KEEP TOPIC instead of PUBLISH TOPIC. PUBLISH broadcasts a fresh message to every subscriber; KEEP stores a value on the broker without broadcasting. Use KEEP for internal state other Actions read later — counters, last-seen timestamps, alarm flags — not for data every dashboard must see live.
Walk through what’s happening:
  1. The trigger inverters/+/power matches inverters/inv-01/power, inverters/inv-47/power, and any other topic that fits.
  2. TOPIC POSITION 2 reads whatever the + matched — so on one trigger it’s inv-01, on the next it’s inv-47. Position counting is 1-based and starts at the topic root.
  3. The published outputs use that value to build per-inverter topics, so each inverter gets its own processed reading and last-seen cache.
This is called wildcard context inheritance: the same + value flows through every GET TOPIC and PUBLISH TOPIC inside the Action, so you don’t have to parse the topic string yourself.
DEFINE ACTION ProcessWithContext
ON TOPIC "sensors/+/temperature" DO
    SET "sensor_id" WITH TOPIC POSITION 2
    SET "humidity" WITH (GET TOPIC "sensors/+/humidity" AS DOUBLE)
    PUBLISH TOPIC "processed/" + {sensor_id} + "/combined" WITH {humidity}
If triggered by sensors/temp001/temperature, the GET TOPIC reads from sensors/temp001/humidity — the wildcard resolves to the same instance. A single Action like this can replace fifty hand-written copies — one for every device. When a new inverter comes online, you don’t deploy new code; the existing Action just starts handling it. The same approach works equally well for hotel rooms (rooms/+/temperature), parking sensors (parking/+/occupied), or delivery vans (vans/+/fuel_level). Anywhere you have many devices doing the same thing, one wildcard Action covers them all.

Counting Things Over Time

Plenty of real-world questions are counting questions. How many cycles has this machine completed today? How many vehicles entered the garage this hour? How many error events has this device emitted this week? LoT has no built-in counter type, but a counter is just a number you keep on a topic and increment whenever something happens.
Like a tally on a bar napkin. Each time something happens, you make another mark. The napkin keeps the count even after you walk away.

When to Reach for This

Whenever you need a value that persists across messages — not just within a single Action run. Production cycles, error totals, visitor counts, button presses, alarms-this-shift, anything that should keep counting whether you reset the system or not.

The Pattern

The trick is two-part: on the very first run there’s no value yet, so you initialize to zero. On every run after that, you read the current value, add one, and write it back.
DEFINE ACTION CountCycles
ON TOPIC "machines/cycle_complete" DO
    IF GET TOPIC "counters/cycles" == EMPTY THEN
        PUBLISH TOPIC "counters/cycles" WITH 0
    ELSE
        PUBLISH TOPIC "counters/cycles" WITH (GET TOPIC "counters/cycles" + 1)
What’s happening:
  1. Every time a cycle_complete message arrives, the Action runs.
  2. The first time ever, counters/cycles doesn’t exist yet, so GET TOPIC returns EMPTY. The Action creates the counter at zero.
  3. Every subsequent run reads the current value, adds one, and publishes the new total.
The counter survives broker restarts as long as the topic is retained, and any dashboard or other Action can subscribe to counters/cycles to see the live count.

Counters Per Device

Combine with a wildcard to keep one counter per device:
DEFINE ACTION CountCyclesPerMachine
ON TOPIC "machines/+/cycle_complete" DO
    SET "machine_id" WITH TOPIC POSITION 2
    IF GET TOPIC "counters/" + {machine_id} + "/cycles" == EMPTY THEN
        PUBLISH TOPIC "counters/" + {machine_id} + "/cycles" WITH 0
    ELSE
        PUBLISH TOPIC "counters/" + {machine_id} + "/cycles"
            WITH (GET TOPIC "counters/" + {machine_id} + "/cycles" + 1)
Now counters/machine-01/cycles, counters/machine-02/cycles, and so on each track their own machine independently — and one Action handles all of them.

Resetting on a Schedule

For shift counters or daily totals, use a scheduled Action to reset:
DEFINE ACTION ResetDailyCounter
ON EVERY 24 HOURS DO
    PUBLISH TOPIC "counters/cycles" WITH 0
Pair it with a scheduled SQL insert to archive the previous total before resetting, and you’ve got a daily history with two short Actions.

Running Python from a LoT Action

LoT is great for the operations that 90% of IoT logic actually needs — math, conditionals, JSON extraction, MQTT publishes. But sometimes you need real programming: regex parsing, statistical analysis, calling a Python library, complex validation rules. For those cases, CALL PYTHON lets a LoT Action invoke a Python function and use the result inline.
Like calling a specialist into a routine job. The general contractor handles the plumbing, framing, and electrical — but when there’s something only a specialist can do, they make a phone call.

When to Stay in LoT vs. Go to Python

Stay in LoT for simple math, string concatenation, basic conditionals, MQTT operations, JSON field extraction, route triggering, scheduled or topic-triggered logic. Anything LoT does natively, do natively — Python adds overhead and complexity. Reach for Python when you need:
  • Regex matching or advanced string parsing
  • Statistical or scientific calculations (averages over windows, anomaly detection, ML inference)
  • Library functions: numpy, pandas, scipy, scikit-learn, custom company libraries
  • Complex validation rules with many conditional branches
  • JSON restructuring beyond simple field extraction
The rule of thumb: if you can do it in LoT, do it in LoT. Python is a tool for capabilities LoT genuinely lacks, not a fallback when LoT is unfamiliar.

When to Reach for This

When your Action has hit the limits of LoT’s expressiveness — typically around regex, statistics, ML, or library calls — and the work isn’t worth wrapping in an external service.

The Pattern

A Python script that validates a complex sensor payload using regex and a custom rule:
# /scripts/Validator.py
import re

def validate_serial(serial):
    pattern = r'^[A-Z]{2}-\d{6}-[A-Z0-9]{4}$'
    if re.match(pattern, serial):
        return {"valid": True, "error": None}
    return {"valid": False, "error": "Serial format invalid"}
A LoT Action that uses it:
DEFINE ACTION ValidateAndStore
ON TOPIC "sensors/registration" DO
    SET "serial" WITH (GET JSON "serial" IN PAYLOAD AS STRING)

    CALL PYTHON "Validator.validate_serial"
        WITH ({serial})
        RETURN AS {result}

    SET "valid" WITH (GET JSON "valid" IN {result} AS BOOL)

    IF {valid} EQUALS TRUE THEN
        PUBLISH TOPIC "registry/valid" WITH PAYLOAD
    ELSE
        SET "err" WITH (GET JSON "error" IN {result} AS STRING)
        PUBLISH TOPIC "registry/rejected" WITH ("Rejected: " + {err})
What’s happening:
  1. The Action receives a sensor registration with a serial number.
  2. It hands the serial to the Python function, which uses regex (something LoT can’t do natively) to validate the format.
  3. The function returns a dictionary, which LoT receives as a JSON object.
  4. The Action extracts the valid and error fields with GET JSON and routes the message accordingly.

Practical Tips

  • Keep Python functions fast (under ~100ms). They run inline in your Action and block message processing while they execute. For heavy computation, consider an external service.
  • Always handle errors. Wrap the Python in try/except and return structured results — {"success": false, "error": "..."} — so the LoT side can branch on it cleanly.
  • Return JSON-friendly values. Dictionaries, lists, strings, numbers, booleans. Custom objects won’t serialize.
  • Convert types explicitly. Python receives parameters as strings — float(value), int(count) at the top of your function.
The full reference is at Python Integration.

Next Steps

Callable Actions

Full reference for INPUT, OUTPUT, and CALL ACTION.

Python Integration

CALL PYTHON syntax, deployment, and best practices.
Last modified on May 20, 2026