Skip to main content

Overview

Lua devices communicate with agents over MQTT (recommended) or Socket.IO. This page documents the complete wire protocol so you can build a client in any language — Go, Rust, C#, Swift, or anything with an MQTT library.
If you’re using Node.js, Python, or MicroPython, use the official SDKs instead. This page is for building clients in languages without an official SDK.

Authentication

Devices authenticate using three credentials passed as MQTT connection parameters:
ParameterMQTT FieldFormatExample
Agent IDusername (before :){agentId}baseAgent_agent_abc123
Device Nameusername (after :){deviceName}warehouse-scanner
API Keypasswordapi_...api_sk_live_abc123
MQTT username format: {agentId}:{deviceName} Connection settings:
  • Broker: wss://mqtt.heylua.ai/mqtt (TLS required in production)
  • Client ID: lua-{agentId}-{deviceName}
  • Clean session: false (enables persistent session for QoS 1 message queueing)
  • Keep-alive: 60 seconds

MQTT Topics

All topics use the prefix lua/devices/{agentId}/{deviceName}/.

Device Subscribes To (Server -> Device)

Topic SuffixQoSDescriptionPayload
command1Incoming command from agentCommandMessage
connected1Server confirms device connection{"message": "..."}
trigger_ack1Server acknowledges a triggerTriggerAckMessage
trigger_result1Result from trigger execution{"triggerName": "...", "result": ...}
error1Error from server{"code": "...", "message": "..."}

Device Publishes To (Device -> Server)

Topic SuffixQoSRetainDescriptionPayload
status1YesOnline/offline status (no secrets)StatusMessage
status1NoAuth + command manifestAuthStatusMessage
response1NoCommand execution resultResponseMessage
trigger1NoFire a trigger to the agentTriggerMessage
heartbeat0NoKeep-alive signalEmpty payload ("")

Socket.IO Events

If you prefer WebSocket transport, connect to {serverUrl}/devices with Socket.IO.
EventDirectionDescriptionPayload
connectedServer -> DeviceConnection confirmed{}
commandServer -> DeviceIncoming commandCommandMessage + ack callback
trigger_ackServer -> DeviceTrigger acknowledgedTriggerAckMessage
trigger_resultServer -> DeviceTrigger execution result{triggerName, result}
errorServer -> DeviceError{code, message}
responseDevice -> ServerCommand resultResponseMessage
triggerDevice -> ServerFire triggerTriggerMessage
heartbeatDevice -> ServerKeep-alive{}
Socket.IO auth is passed in the auth option at connection time:
{
  "apiKey": "api_sk_live_abc123",
  "agentId": "baseAgent_agent_abc123",
  "deviceName": "warehouse-scanner",
  "group": "warehouse-a",
  "commands": [...]
}

Message Schemas

CommandMessage

Received on the command topic when the agent invokes a device command.
{
  "commandId": "cmd_abc123",
  "command": "scan_barcode",
  "payload": { "format": "qr" },
  "timeout": 30000
}
FieldTypeRequiredDescription
commandIdstringYesUnique ID for idempotency
commandstringYesCommand name matching a registered handler
payloadanyNoInput parameters for the command
timeoutnumberNoTimeout in milliseconds (default: 30000)

ResponseMessage

Published to the response topic after executing a command.
{
  "commandId": "cmd_abc123",
  "success": true,
  "data": { "barcode": "ABC-12345", "format": "CODE128" }
}
{
  "commandId": "cmd_abc123",
  "success": false,
  "error": "Scanner hardware not responding"
}
FieldTypeRequiredDescription
commandIdstringYesMust match the incoming command’s ID
successbooleanYesWhether the command succeeded
dataanyNoResult data (on success)
errorstringNoError message (on failure)

TriggerMessage

Published to the trigger topic to fire an event to the agent.
{
  "triggerName": "barcode_scanned",
  "payload": { "value": "ABC-12345", "location": "aisle-3" }
}
FieldTypeRequiredDescription
triggerNamestringYesName of the trigger
payloadanyNoTrigger data sent to the agent

TriggerAckMessage

Received on the trigger_ack topic after the server processes a trigger.
{
  "triggerId": "trg_abc123",
  "received": true
}
FieldTypeRequiredDescription
triggerIdstringYesServer-assigned trigger ID
receivedbooleanYesWhether the trigger was accepted
errorstringNoError message if rejected

StatusMessage (retained)

Published to the status topic with retain: true. This message must NOT contain secrets (the API key) because retained messages are stored by the broker and delivered to any future subscriber.
{
  "status": "online",
  "timestamp": "2026-04-17T10:30:00.000Z",
  "group": "warehouse-a"
}

AuthStatusMessage (non-retained)

Published to the status topic with retain: false immediately after the retained status. Contains the API key and command manifest for server-side authentication.
{
  "status": "online",
  "apiKey": "api_sk_live_abc123",
  "group": "warehouse-a",
  "commands": [
    {
      "name": "scan_barcode",
      "description": "Scan a barcode and return its value",
      "inputSchema": {
        "type": "object",
        "properties": {
          "format": { "type": "string", "enum": ["qr", "code128", "ean13"] }
        }
      },
      "timeoutMs": 30000
    }
  ]
}

Heartbeat

Published to the heartbeat topic every 30 seconds with an empty payload. QoS 0 (fire-and-forget).

Last Will and Testament (LWT)

Set the MQTT LWT to publish an offline status if the device disconnects unexpectedly:
  • Topic: lua/devices/{agentId}/{deviceName}/status
  • Payload: {"status": "offline", "timestamp": "..."}
  • QoS: 1
  • Retain: true

Self-Describing Commands

Commands are sent at connect time in the AuthStatusMessage. The server registers them as agent tools automatically — no compile/push cycle needed.
{
  "name": "read_temperature",
  "description": "Read current temperature in celsius from the DHT22 sensor",
  "inputSchema": {
    "type": "object",
    "properties": {
      "unit": { "type": "string", "enum": ["celsius", "fahrenheit"] }
    }
  },
  "timeoutMs": 5000,
  "retry": { "maxAttempts": 3, "backoffMs": 1000 }
}
FieldTypeRequiredDescription
namestringYesCommand name (used by agent to invoke)
descriptionstringYesShown to the AI agent as tool description
inputSchemaobjectNoJSON Schema for command parameters
timeoutMsnumberNoTimeout in ms (default: 30000)
retryobjectNo{maxAttempts, backoffMs}

Command Lifecycle

Idempotency

Commands include a commandId that must be used for idempotency. Your client should:
  1. Maintain an LRU cache of recently seen commandId values (recommended: 1000 entries, 5-minute TTL)
  2. On receiving a command, check if the commandId has been seen before
  3. If seen, re-publish the cached response without re-executing the handler
  4. If new, execute the handler, cache the response, then publish it
This ensures that retried messages (common with QoS 1) do not cause duplicate side effects.
Without idempotency handling, MQTT QoS 1 redelivery can cause commands to execute multiple times. Always implement the dedup cache.

Rate Limits and Constraints

ConstraintValue
Max payload size (MQTT)256 KB
Max commands per device50
Heartbeat interval30 seconds
Trigger ACK timeout10 seconds
Command default timeout30 seconds
Dedup cache TTL5 minutes
Dedup cache size1000 entries
Reconnect base delay1 second
Reconnect max delay30 seconds
MQTT keep-alive60 seconds

Example Implementations

package main

import (
    "encoding/json"
    "fmt"
    mqtt "github.com/eclipse/paho.mqtt.golang"
    "os"
    "os/signal"
    "time"
)

func main() {
    agentID := "baseAgent_agent_abc123"
    deviceName := "go-sensor"
    apiKey := "api_your_key"
    prefix := fmt.Sprintf("lua/devices/%s/%s/", agentID, deviceName)

    opts := mqtt.NewClientOptions().
        AddBroker("wss://mqtt.heylua.ai/mqtt").
        SetClientID(fmt.Sprintf("lua-%s-%s", agentID, deviceName)).
        SetUsername(fmt.Sprintf("%s:%s", agentID, deviceName)).
        SetPassword(apiKey).
        SetKeepAlive(60 * time.Second).
        SetCleanSession(false).
        SetWill(prefix+"status",
            `{"status":"offline","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`,
            1, true)

    client := mqtt.NewClient(opts)
    if token := client.Connect(); token.Wait() && token.Error() != nil {
        panic(token.Error())
    }

    // Subscribe to commands
    client.Subscribe(prefix+"command", 1, func(c mqtt.Client, msg mqtt.Message) {
        var cmd map[string]interface{}
        json.Unmarshal(msg.Payload(), &cmd)

        response := map[string]interface{}{
            "commandId": cmd["commandId"],
            "success":   true,
            "data":      map[string]interface{}{"temp": 22.5},
        }
        payload, _ := json.Marshal(response)
        c.Publish(prefix+"response", 1, false, payload)
    })

    // Publish online status
    online, _ := json.Marshal(map[string]string{"status": "online", "timestamp": time.Now().UTC().Format(time.RFC3339)})
    client.Publish(prefix+"status", 1, true, online)
    auth, _ := json.Marshal(map[string]interface{}{"status": "online", "apiKey": apiKey, "commands": []interface{}{}})
    client.Publish(prefix+"status", 1, false, auth)

    fmt.Println("Device connected. Press Ctrl+C to exit.")
    sig := make(chan os.Signal, 1)
    signal.Notify(sig, os.Interrupt)
    <-sig
    client.Disconnect(250)
}
These examples show the minimal connect-and-handle pattern. Production clients should add: idempotency dedup, heartbeat loop, LWT, graceful shutdown, error handling, and auto-reconnect with exponential backoff.