> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# MQTT protocol

> Topics, message payloads, QoS, limits, error codes, and per-topic authorization for a device connecting over MQTT

The MQTT protocol is what the Node, Python, and MicroPython clients speak to the platform; use this page to write a [device](/concepts/devices) client in another language or to debug one. Socket.IO carries the same messages as events; the mapping is at the end.

*Verified against @lua-ai-global/device-client 1.1.0 and lua-device-client 1.3.0.*

## Connection

| Parameter     | Value                                                                                    |
| ------------- | ---------------------------------------------------------------------------------------- |
| Broker        | `wss://mqtt.heylua.ai/mqtt` (MQTT 3.1.1 over WebSocket, TLS, port 443)                   |
| Client ID     | `lua-<agentId>-<deviceName>`                                                             |
| Username      | `<agentId>:<deviceName>`; neither part may contain whitespace, `/`, `+`, or `#`          |
| Password      | The [device credential](/devices/credentials) or a legacy API key                        |
| Clean session | `false`, so commands published while the device is away are queued                       |
| Keep-alive    | 60 seconds                                                                               |
| Last will     | Topic `status`, payload `{"status":"offline","timestamp":"<ISO 8601>"}`, QoS 1, retained |

The broker refuses the CONNECT (`Not authorized`) when the credential is rejected, when its binding doesn't match the username, or when a scoped personal API key is used.&#x20;

## Topics

Every topic is `lua/devices/<agentId>/<deviceName>/<suffix>`.

| Suffix           | Direction         | QoS | Retained | Payload                                              |
| ---------------- | ----------------- | --- | -------- | ---------------------------------------------------- |
| `status`         | Device publishes  | 1   | Yes      | Presence, without the credential                     |
| `status`         | Device publishes  | 1   | No       | Presence with the command list                       |
| `heartbeat`      | Device publishes  | 0   | No       | Empty                                                |
| `response`       | Device publishes  | 1   | No       | Command result                                       |
| `trigger`        | Device publishes  | 1   | No       | Trigger                                              |
| `ping`           | Device publishes  | 1   | No       | Empty; the platform replies on `pong`                |
| `command`        | Device subscribes | 1   | No       | Command to run                                       |
| `connected`      | Device subscribes | 1   | No       | Connection confirmed                                 |
| `trigger_ack`    | Device subscribes | 1   | No       | Trigger queued                                       |
| `trigger_error`  | Device subscribes | 1   | No       | Trigger rejected; no shipped client subscribes to it |
| `error`          | Device subscribes | 1   | No       | Connection or response error                         |
| `pong`           | Device subscribes | 1   | No       | Reply to `ping`                                      |
| `trigger_result` | Device subscribes | 1   | No       | Never published by the platform                      |

## Messages

### status (retained)

Published first, so a late subscriber sees the device's presence. It must not contain the credential; the broker stores retained messages.

```json theme={null}
{ "status": "online", "timestamp": "2026-09-12T10:30:00.000Z", "group": "warehouse-floor" }
```

### status (command list)

Published right after the retained message, with `retain` false. The platform registers the device and stores the commands when it receives this message. The Node client adds `"apiKey": "<key>"` only for a legacy key; the Python and MicroPython clients always add it. When it's absent the platform uses the identity proven at CONNECT; when present, it's validated again. `clientFamily` and `clientVersion` are diagnostics.

```json theme={null}
{
  "status": "online",
  "group": "warehouse-floor",
  "commands": [
    {
      "name": "scan_barcode",
      "description": "Scan a barcode and return its value",
      "inputSchema": { "type": "object", "properties": { "format": { "type": "string", "enum": ["qr", "code128"] } } },
      "timeoutMs": 10000,
      "retry": { "maxAttempts": 2, "backoffMs": 1000 }
    }
  ],
  "clientFamily": "device-python",
  "clientVersion": "1.3.0"
}
```

Publishing `{"status":"offline"}` on `status` (retained) is how a client disconnects cleanly; the will message does the same when the connection drops.

### command

```json theme={null}
{ "commandId": "0b0d0b2e-4a25-4a3e-9c3e-1e4d0f6a7b8c", "command": "scan_barcode", "payload": { "format": "qr" } }
```

`commandId` is a UUID. Keep the last 1,000 IDs for 5 minutes and re-publish the cached response for a repeated ID; QoS 1 can deliver a command twice. The platform doesn't send a `timeout` field; the timeout is enforced server-side.

### response

```json theme={null}
{ "commandId": "0b0d0b2e-4a25-4a3e-9c3e-1e4d0f6a7b8c", "success": true, "data": { "value": "ABC-12345" } }
```

`commandId` (string) and `success` (boolean) are required. `data` is any JSON up to 1 MB; `error` is a string, present when `success` is false, and its 4 KB cap is enforced over Socket.IO only. A response for an unknown, expired, or already-settled command is ignored.

### trigger

```json theme={null}
{ "triggerName": "low_stock_alert", "payload": { "sku": "SKU-2024-0847", "quantity": 3 } }
```

`triggerName` is matched exactly against the handlers on the agent. `payload` is any JSON up to 1 MB.

### trigger\_ack

```json theme={null}
{ "triggerId": "5f1c3a2e-6d7b-4c8a-9e0f-1a2b3c4d5e6f", "received": true }
```

Sent once the event is queued. A handler may still be missing on the agent; see [Handle device triggers](/devices/triggers).

### connected

```json theme={null}
{ "message": "Connected to device gateway (MQTT)", "deviceName": "warehouse-station-01", "agentId": "agent_abc123", "timestamp": "2026-09-12T10:30:00.100Z" }
```

### error and trigger\_error

```json theme={null}
{ "code": "RATE_LIMITED", "message": "Exceeded 10 triggers/s" }
```

### pong

```json theme={null}
{ "timestamp": "2026-09-12T10:30:05.000Z" }
```

## Limits

| Limit                                             | Value                                                                          |
| ------------------------------------------------- | ------------------------------------------------------------------------------ |
| Command payload, response `data`, trigger payload | 1 MB (1,048,576 bytes)                                                         |
| Response `error`                                  | 4 KB, enforced over Socket.IO                                                  |
| Commands per device                               | 128; the rest are dropped                                                      |
| `inputSchema` per command                         | 4 KB serialized; larger drops the command                                      |
| Command name                                      | `^[a-z][a-z0-9_]{0,63}$`                                                       |
| Description                                       | HTML stripped, cut at 500 characters                                           |
| Device tools per agent                            | 128                                                                            |
| Triggers                                          | 10 per second per agent                                                        |
| Commands in flight                                | 100 per agent                                                                  |
| Command timeout                                   | 30 seconds default, plus 2 seconds grace                                       |
| Heartbeat                                         | Every 30 seconds; offline after 5 minutes without one, checked every 5 minutes |

## Error codes

| Code                | Topic                    | Meaning                                                                                     |
| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------- |
| `AUTH_FAILED`       | `error`                  | The `apiKey` in the status message was rejected                                             |
| `AUTH_REQUIRED`     | `error`                  | The status message carried no `apiKey` and the CONNECT wasn't made with a device credential |
| `INVALID_RESPONSE`  | `error`                  | `commandId` or `success` missing or of the wrong type                                       |
| `PAYLOAD_TOO_LARGE` | `error`, `trigger_error` | Response `data` or trigger payload over 1 MB                                                |
| `INVALID_TRIGGER`   | `trigger_error`          | `triggerName` missing                                                                       |
| `RATE_LIMITED`      | `trigger_error`          | More than 10 triggers per second for the agent                                              |

A message a credential isn't allowed to publish is refused by the broker's authorization check and never reaches the platform, so a device without the `triggers` operation gets no `trigger_error` for a trigger; the publish is denied.

## Authorization by topic

A device credential is checked on every publish and subscribe. The topic must be under the credential's own `<agentId>/<deviceName>` prefix, and the suffix must be allowed for a granted operation. The credential is re-validated at the next publish or subscribe after 60 seconds; a revoked one is denied and its connection state dropped.

| Suffix                          | Publish needs  | Subscribe needs |
| ------------------------------- | -------------- | --------------- |
| `response`                      | `commands`     | —               |
| `command`                       | —              | `commands`      |
| `trigger`                       | `triggers`     | —               |
| `trigger_ack`, `trigger_result` | —              | `triggers`      |
| `status`, `heartbeat`, `ping`   | any credential | —               |
| `connected`, `error`, `pong`    | —              | any credential  |

A legacy API key may publish and subscribe to anything under its own prefix.

## Socket.IO equivalents

A Socket.IO client connects to `https://api.heylua.ai/devices` with `transports: ['websocket']` and `auth: { apiKey, agentId, deviceName, group?, commands?, deviceKind?, clientVersion? }`; `apiKey` carries the device credential. Messages are events with the same names and payloads: the platform emits `connected`, `command` (with an acknowledgment callback the device must call), `trigger_ack`, `trigger_error`, `error`, and `pong`; the device emits `response`, `trigger`, `heartbeat`, and `ping`. Socket.IO adds the handshake codes `MISSING_AUTH`, `AUTH_FAILED`, `AGENT_FORBIDDEN`, `DEVICE_DISABLED`, `CONNECT_ERROR`, `RATE_LIMITED` (with `retryAfterMs`), plus `OPERATION_FORBIDDEN` and `COMMAND_ROUTE_MISMATCH` on `error`.

## See also

* [Node device client](/devices/node-client) — the reference implementation of both transports
* [Python device client](/devices/python-client)
* [Reliability and limits](/devices/reliability-and-limits)
* [How devices work](/devices/how-it-works)
