Skip to main content

What You Build With LoT

Everything in LoT (Language of Things) is built by defining four types of entities — Actions, Models, Routes, and Rules. Each one handles a different part of your automation, and inside them you use commands like PUBLISH, SET, and GET to make things happen.
Like building with LEGO blocks. You pick the right block for the job — Actions for logic, Models for data structure, Routes for connections, Rules for security — and snap them together into a complete system.

When to Use This

  • You’re learning LoT and want to understand the building blocks and how they fit together
  • 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


The Four Building Blocks

Everything in LoT is built from four core constructs, each created with the DEFINE keyword:
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 these commands to make things happen:
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
IF/THENConditional logic
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
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

Working with JSON

Most MQTT payloads in IoT systems are JSON. LoT gives you two built-in ways to handle it — read individual fields from incoming JSON with GET JSON, and write structured JSON output using Models.

Reading JSON Fields

Use GET JSON inside an Action to extract typed values from a JSON payload. Always specify the target type with AS to ensure correct handling in comparisons and math.
SyntaxWhat it does
GET JSON "key" IN PAYLOAD AS DOUBLEExtract a numeric value
GET JSON "key" IN PAYLOAD AS STRINGExtract a text value
GET JSON "key" IN PAYLOAD AS INTExtract an integer value
GET JSON "key" IN PAYLOAD AS BOOLExtract a boolean value

Writing JSON Output

To publish structured JSON, define a COLLAPSED Model with typed fields and use PUBLISH MODEL ... TO ... WITH from an Action. The broker builds the JSON for you — no manual string formatting needed.

Putting It Together

This example reads multiple fields from an incoming JSON payload and publishes them as structured output through a Model:
DEFINE MODEL SensorDataRecord COLLAPSED
    ADD STRING "sensor_id"
    ADD STRING "sensor_type"
    ADD DOUBLE "temperature"
    ADD DOUBLE "pressure"
    ADD STRING "timestamp"
    ADD STRING "quality_status"

DEFINE ACTION ProcessSensorJSON
ON TOPIC "sensors/+/json_data" DO
    SET "sensor_id" WITH TOPIC POSITION 2

    SET "temperature_val" WITH (GET JSON "temperature" IN PAYLOAD AS DOUBLE)
    SET "pressure_val" WITH (GET JSON "pressure" IN PAYLOAD AS DOUBLE)
    SET "sensor_type" WITH (GET JSON "type" IN PAYLOAD AS STRING)
    SET "quality" WITH (GET JSON "quality_status" IN PAYLOAD AS STRING)

    PUBLISH MODEL SensorDataRecord TO "sensors/processed/" + {sensor_id} WITH
        sensor_id = {sensor_id}
        sensor_type = {sensor_type}
        temperature = {temperature_val}
        pressure = {pressure_val}
        timestamp = TIMESTAMP "UTC"
        quality_status = {quality}
StepWhat Happens
1Action triggers when any sensor publishes JSON to sensors/+/json_data
2GET JSON extracts each field with the correct type
3PUBLISH MODEL builds a clean JSON object and publishes it to the processed topic

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

Write Your First Action

Learn the full syntax for triggers, variables, and control flow.

AI-Assisted Development

Set up your AI assistant with LoT conventions using AGENTS.md templates.