Skip to main content

Why Paradigms Matter

LoT (Language of Things) combines two programming approaches—imperative and declarative—each optimized for different tasks. Understanding when to use each one makes your code cleaner, more maintainable, and easier to debug.
Like a restaurant kitchen. Imperative commands are the chef shouting “Plate this now!” (immediate actions). Declarative definitions are the recipes and menu (structures that define how things work when triggered).

When to Use This

  • You’re learning LoT and want to understand the core paradigms and building blocks
  • You need a naming and conventions reference for entities, variables, and topics
  • You’re designing a topic hierarchy for a new or growing system
  • You want proven code patterns to follow — and anti-patterns to avoid

In This Page


Two Ways to Write LoT

LoT uses two syntax styles based on what you’re doing:
Direct actions executed immediately by the broker. Use these inside Actions to do things right now.
PUBLISH TOPIC "alerts/temperature" WITH PAYLOAD
SET "counter" WITH (GET TOPIC "stats/count" + 1)
KEEP TOPIC "data/cached" WITH PAYLOAD
CALL ACTION "ProcessData"
SET "host" WITH (GET ENV "DB_HOST")
SET "pass" WITH (GET SECRET "DB_PASSWORD")
CommandWhat it does
PUBLISHSend a message to a topic
SETStore a value in a variable
GETRetrieve a value from a topic or variable
GET ENVRead an environment variable
GET SECRETRead an encrypted secret
KEEPPublish and retain a message
CALLExecute another Action

When to Use Each Paradigm

ScenarioParadigmExample
Publish a message nowImperativePUBLISH TOPIC "status" WITH "online"
React when data arrivesDeclarativeDEFINE ACTION ... ON TOPIC "input" DO
Store a calculation resultImperativeSET "total" WITH ({a} + {b})
Structure JSON outputDeclarativeDEFINE MODEL ... ADD STRING "id"
Connect to a databaseDeclarativeDEFINE ROUTE ... TO POSTGRESQL
Read configurationImperativeGET ENV "DB_HOST"
Read credentialsImperativeGET SECRET "DB_PASSWORD"
Forward data in a loopImperativePUBLISH TOPIC "out" WITH PAYLOAD

The Four Building Blocks

Everything in LoT is built from four declarative constructs:
ConstructPurposeTrigger Types
ActionsExecute logic when events occurTime, Topic, System events, Manual
ModelsStructure MQTT data into JSON schemasTopic values, Action calls
RoutesConnect to external systemsTopic patterns
RulesControl who can publish/subscribeClient identity, Topic patterns
Actions contain your automation logic. They run when triggered:
TriggerSyntaxUse Case
Time-basedON EVERY 5 SECONDSHeartbeats, polling, scheduled tasks
Topic-basedON TOPIC "sensors/+"React to incoming data
CallableNo trigger clauseUtility functions, subroutines
SystemON START, ON STOPInitialization, cleanup
Inside an Action, you use imperative commands like PUBLISH, SET, IF/THEN, and CALL.
Models define JSON structures that auto-publish when their trigger fields update:
Field TypePurpose
STRING, INT, DOUBLE, BOOLTyped data fields
AS TRIGGERField that activates the model when it changes
FROMInherit fields from another model
Models eliminate manual JSON formatting—define the schema once, and data flows automatically.
Routes connect your MQTT infrastructure to external systems:
CategoryExamples
Data StoragePostgreSQL, MongoDB, CrateDB, OpenSearch
Data PipelineMQTT bridges, email, HTTP webhooks
IndustrialOPC-UA, Modbus, Siemens S7, Allen-Bradley
SystemBroker clustering, replication
Routes are declarative—you define what to connect, and the broker handles the how.
Rules define access control at the broker level:
ControlDescription
Publish permissionsWho can send messages to which topics
Subscribe permissionsWho can receive messages from which topics
Client identityMatch rules to specific clients or patterns
Rules enforce security policies without modifying your Actions or Models.

How They Work Together

A typical LoT solution combines all four building blocks:
-- 1. ROUTE: Connect to PostgreSQL
DEFINE ROUTE SensorDB TO POSTGRESQL
    CONNECTION "Host=db.local;Database=iot"
    TOPIC "sensors/#"

-- 2. MODEL: Structure incoming data
DEFINE MODEL SensorReading WITH TOPIC "sensors/formatted"
    ADD STRING "device_id" WITH TOPIC "sensors/id"
    ADD DOUBLE "temperature" WITH TOPIC "sensors/raw" AS TRIGGER

-- 3. ACTION: Process and alert
DEFINE ACTION TemperatureAlert
ON TOPIC "sensors/formatted" DO
    SET "temp" WITH (GET JSON "temperature" IN PAYLOAD AS DOUBLE)
    IF {temp} > 80 THEN
        PUBLISH TOPIC "alerts/high_temp" WITH PAYLOAD

-- 4. RULE: Restrict who can publish alerts
DEFINE RULE AlertAccess
    DENY PUBLISH "alerts/#" FOR ALL
    ALLOW PUBLISH "alerts/#" FOR "system-*"
StepWhat Happens
1Route stores all sensors/# data to PostgreSQL
2Model transforms raw data into structured JSON
3Action checks thresholds and publishes alerts
4Rule ensures only system clients can write alerts

Naming Conventions

Consistent naming is the single most impactful practice for maintainable LoT systems. These conventions apply to all LoT entities.

Entity Names

All LoT entities use PascalCase. Names should be descriptive and purpose-driven.
EntityConventionGood ExamplesBad Examples
ActionsPascalCase, verb-firstProcessTemperature, MonitorPressure, SendDailyReporttemp_process, action1, myAction
ModelsPascalCase, noun-basedSensorReading, EquipmentStatus, ProductionRecordsensor_model, Model1, data
RulesPascalCase, descriptive scopeAllowAdminActions, ProtectSysTopics, RestrictDevicePublishRule1, myRule, newRule
RoutesPascalCase, destination-basedCloudBridge, SensorDatabase, AlertEmailroute1, my_route, dbRoute
Callable ActionsPascalCase, function-likeCalculateAverage, ConvertCelsiusToFahrenheitcalc, helper, util

Variable Names

Variables use snake_case inside double quotes for SET declarations, and curly braces for references.
DEFINE ACTION ProcessSensorData
ON TOPIC "sensors/+/raw" DO
    SET "sensor_id" WITH TOPIC POSITION 2
    SET "raw_value" WITH (GET JSON "value" IN PAYLOAD AS DOUBLE)
    SET "converted_value" WITH ({raw_value} * 1.8 + 32)
    PUBLISH TOPIC "processed/" + {sensor_id} + "/fahrenheit" WITH {converted_value}
ContextConventionExamples
Declaration (SET)snake_case in double quotes"sensor_id", "raw_value", "cycle_time"
ReferenceCurly braces{sensor_id}, {raw_value}, {cycle_time}
Model fieldssnake_case in double quotes"equipment_id", "runtime_hours", "last_update"
Action inputssnake_case after keywordINPUT value AS DOUBLE, INPUT threshold AS DOUBLE

Topic Names

Topics use lowercase with forward-slash separators. Multi-word segments use snake_case. Whenever possible, follow a Unified Namespace (UNS) format for topic naming. This enables not only proper readability but also scalability of systems.
PatternExampleUse Case
domain/entity/attributesensors/temperature/valueGeneral data
domain/instance/attributesensors/temp001/rawInstance-specific data
domain/category/instancealerts/critical/pump03Categorized events
Prefixed namespacesprocessed/, alerts/, config/, cache/, state/, system/Functional separation

Topic Hierarchy Design

A well-designed topic tree is the most critical architectural decision in a LoT system. Every entity — Actions, Models, Rules, and Routes — communicates through topics. A clean hierarchy makes the entire system easier to build, debug, and extend. Organize topics into functional namespaces that reflect the data lifecycle:
NamespacePurposeExample Topics
sensors/Raw incoming sensor datasensors/temp001/raw, sensors/+/temperature
processed/Transformed or enriched dataprocessed/temp001/fahrenheit, processed/+/status
alerts/Threshold violations and notificationsalerts/critical/pump03, alerts/+/temperature
config/Configuration values read by Actionsconfig/setpoint, config/max_temperature
state/Internal persistent state (used with KEEP)state/counter, state/last_run
cache/Cached values for quick referencecache/temp001/last_reading
system/Heartbeats, health checks, diagnosticssystem/heartbeat, system/status
commands/Inbound instructions from external systemscommands/devices/+/restart

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. A wildcard-triggered Action like ON TOPIC "sensors/+/raw" automatically resolves + in GET TOPIC and PUBLISH TOPIC to the same matched value — this is called wildcard context inheritance:
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}
In this example, if triggered by sensors/temp001/temperature, the GET TOPIC reads from sensors/temp001/humidity — the wildcard resolves to the same instance.

Code Patterns

Proven templates and patterns for common LoT logic. Each pattern addresses a specific architectural need — use them as starting points for your own implementations.

Actions: Single Responsibility

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}

Actions: State Management

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)

Models: Trigger Selection

Mark the primary data field as AS TRIGGER — never a timestamp or metadata field. The trigger determines when the model publishes, so it should fire when new meaningful data arrives:
DEFINE MODEL SensorReading WITH TOPIC "sensors/formatted/temperature"
    ADD STRING "sensor_id" WITH "TEMP001"
    ADD DOUBLE "value" WITH TOPIC "sensors/raw/temperature" AS TRIGGER
    ADD STRING "unit" WITH "celsius"
    ADD STRING "timestamp" WITH TIMESTAMP "UTC"
Use COLLAPSED models when you need full control over publishing timing and destination — the Action decides when and where to publish:
DEFINE MODEL AlarmRecord COLLAPSED
    ADD STRING "alarm_id"
    ADD STRING "equipment_id"
    ADD STRING "severity"
    ADD STRING "timestamp"

DEFINE ACTION ProcessAlarm
ON TOPIC "alarms/+/raw" DO
    SET "equip_id" WITH TOPIC POSITION 2
    PUBLISH MODEL AlarmRecord TO "alarms/structured/" + {equip_id} WITH
        alarm_id = (RANDOM UUID)
        equipment_id = {equip_id}
        severity = (GET JSON "severity" IN PAYLOAD AS STRING)
        timestamp = TIMESTAMP "UTC"

Models: Inheritance for Consistency

When you have a family of related data types, define a base COLLAPSED model and extend it:
DEFINE MODEL BaseAlert COLLAPSED
    ADD STRING "alert_id"
    ADD STRING "timestamp"
    ADD STRING "severity"
    ADD STRING "message"

DEFINE MODEL TemperatureAlert FROM BaseAlert
    ADD DOUBLE "temperature_value"
    ADD DOUBLE "threshold"
    ADD STRING "sensor_location"

DEFINE MODEL PressureAlert FROM BaseAlert
    ADD DOUBLE "pressure_value"
    ADD DOUBLE "max_safe_pressure"
    ADD STRING "system_affected"

Rules: Priority and Scope

Rules are evaluated by priority (lower number = higher priority). Structure them as specific deny rules first, then broader allow rules:
DEFINE RULE ProtectSysTopics WITH PRIORITY 10 FOR PublishSys
    IF USER IS "root" THEN
        ALLOW
    ELSE
        DENY

DEFINE RULE AllowDevicePublish WITH PRIORITY 50 FOR Publish TO TOPIC "sensors/#"
    IF USER HAS AllowedSensorPublish THEN
        ALLOW
    ELSE
        DENY
PracticeGuideline
Priority 1–20Critical deny rules (system protection)
Priority 21–50Specific allow rules (per-feature access)
Priority 51–100General allow rules (broad access)
Condition stylePrefer USER HAS <permission> over USER IS "<name>" for maintainability

Routes: Clear Configuration

Name route mappings descriptively and group related mappings within a single route definition:
DEFINE ROUTE CloudSync WITH TYPE MQTT_BRIDGE
    ADD SOURCE_CONFIG
        WITH BROKER SELF
    ADD DESTINATION_CONFIG
        WITH BROKER_ADDRESS "iot.cloudprovider.com"
        WITH BROKER_PORT '8883'
        WITH CLIENT_ID "EdgeDevice-Factory1"
        WITH USE_TLS true
    ADD MAPPING sensorData
        WITH SOURCE_TOPIC "sensors/#"
        WITH DESTINATION_TOPIC "factory1/sensors/#"
        WITH DIRECTION "out"
    ADD MAPPING inboundCommands
        WITH SOURCE_TOPIC "local/commands/#"
        WITH DESTINATION_TOPIC "factory1/commands/#"
        WITH DIRECTION "in"

Anti-Patterns

These are the most common mistakes in LoT development. Avoid them whether you’re writing new code or reviewing existing output.
Anti-PatternWhy It’s BadDo This Instead
Omitting type casts in mathImplicit type handling leads to silent errorsAlways cast: PAYLOAD AS DOUBLE, GET TOPIC ... AS DOUBLE
Triggering models on timestampsTimestamps update every tick — model fires constantlyTrigger on the primary data field: AS TRIGGER on value, not timestamp
Inconsistent naming across modelssensorID in one model, sensor_id in anotherStandardize on snake_case for all field names
Using Python for native LoT tasksPython adds overhead for simple publish/get/if operationsUse Python only for math libraries, ML, API calls, or complex parsing
Leaving $SYS/# topics unrestrictedSystem topics contain broker commands and sensitive dataCreate a priority-10 Rule restricting PublishSys and SubscribeSys
Generic entity namesAction1, Rule1, MyRoute are meaningless in a system with 50 entitiesName by purpose: MonitorPressure, ProtectSysTopics, SensorDatabase
PUBLISH for internal stateBroadcasts data that only your own Actions needUse KEEP TOPIC for internal state; PUBLISH for external subscribers
Monolithic ActionsOne Action doing 15 things is hard to test and debugSplit into callable Actions with INPUT/OUTPUT

Next Steps