Embedded & IoT No. 463

MQTT for IoT skill for Claude Code

Topics, QoS and ACLs that scale past ten devices. Claude designs MQTT systems from a message catalog: one topic scheme with the device ID in a fixed level, a stated QoS and retain choice per message, retained status driven by last will, and commands with expiry and idempotency. It configures Mosquitto or EMQX with TLS, per-device credentials and ACL patterns, and proves isolation with explicit denial tests.

  • namemqtt-iot
  • size194 lines · ≈2,350 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Designs and debugs MQTT for IoT fleets — topic hierarchies, QoS selection, retained messages, last will, persistent sessions, MQTT 5 features (session expiry, message expiry, shared subscriptions, reason codes), device client reconnect logic, and broker setup and hardening on Mosquitto 2.x or EMQX with TLS, per-device credentials and ACLs. Use when connecting ESP32, Raspberry Pi or other devices to a broker, designing topic schemes, when clients disconnect in loops, messages are lost or duplicated, retained state goes stale, or when securing a broker exposed to the internet.

Try asking

  • Design the MQTT topic structure and QoS choices for 500 sensors across three sites
  • My devices keep connecting and disconnecting from Mosquitto every few seconds
  • Lock down our Mosquitto broker so each device can only touch its own topics

Use it when

  • Designing topic hierarchies and message types for a device fleet
  • Choosing QoS, retained messages, sessions, or last will settings
  • Setting up or hardening Mosquitto or EMQX with TLS and ACLs
  • Clients loop on reconnect, lose messages, or replay old commands

Skip it when

How to check the result

Cross-device ACL attempts fail, a powered-off device shows offline within about 1.5x keepalive, and a broker restart produces a jittered reconnect spread with no lost queued commands.

The common mistake it prevents

Giving every device the same client ID or credentials, which causes endless kick-reconnect loops and lets one leaked device read the whole fleet.

~/.claude/skills/mqtt-iot/SKILL.md

--- name: mqtt-iot description: Designs and debugs MQTT for IoT fleets — topic hierarchies, QoS selection, retained messages, last will, persistent sessions, MQTT 5… license: MIT ---

Done well means: topics follow one documented scheme, each message type has a chosen QoS and retain flag with a reason, every device has a unique client ID and its own credentials restricted by ACL to its own topics, connections use TLS, devices reconnect with jittered backoff, and offline devices are visible through last-will status.

Ground rules

  • Never expose port 1883 (plaintext) to the internet. Use 8883 with TLS; plaintext only on a trusted local network or localhost.
  • One identity per device: unique client ID, unique username/cert. Shared credentials mean one leaked device compromises the fleet and ACLs cannot isolate devices.
  • Duplicate client IDs kick each other off in an endless reconnect loop. Derive the ID from hardware (MAC, chip ID, serial).
  • Devices subscribe only to their own command topics, never #.
  • Treat every received payload as untrusted input: validate size, schema, and ranges.

Workflow

- [ ] 1. Write the topic scheme and message catalog (topic, direction, QoS, retain, payload schema)
- [ ] 2. Choose broker and deployment; configure listeners, TLS, auth, ACLs
- [ ] 3. Implement the device client: unique ID, LWT, backoff, session settings
- [ ] 4. Implement backend consumers (shared subscriptions for scale-out)
- [ ] 5. Test with mosquitto_sub/pub; test ACL denials explicitly
- [ ] 6. Failure tests: broker restart, network drop, device power loss, slow consumer
- [ ] 7. Monitor: connected clients, message rates, dropped/queued messages

1. Topic scheme

<org>/<site>/<device-id>/telemetry/<sensor>     device → cloud, frequent
<org>/<site>/<device-id>/state                  device → cloud, retained (current state)
<org>/<site>/<device-id>/status                 "online"/"offline", retained, set by LWT
<org>/<site>/<device-id>/cmd/<command>          cloud → device
<org>/<site>/<device-id>/cmd/<command>/result   device → cloud, response
<org>/<site>/<device-id>/config                 cloud → device, retained desired config

Rules: no leading / (it creates an empty first level), lowercase, no spaces, device ID in a fixed position (so ACL patterns work), no personal data in topic names (they show up in broker logs). Version the payload ("v": 2), not the topic, unless the change is incompatible.

Message catalog (one row per message type)

| Topic | Dir | QoS | Retain | Payload | Rate |
|---|---|---|---|---|---|
| acme/plant1/{id}/telemetry/temp | D→C | 0 | no | {"v":1,"ts":1730000000,"c":21.4} | 1/min |
| acme/plant1/{id}/status | D→C | 1 | yes | "online" / LWT "offline" | on change |
| acme/plant1/{id}/cmd/reboot | C→D | 1 | no | {"id":"uuid","after_s":5} | rare |
| acme/plant1/{id}/config | C→D | 1 | yes | {"v":1,"interval_s":60} | on change |

QoS and retain decisions

MessageQoSRetainWhy
Frequent telemetry, next sample supersedes0noLoss tolerable, cheapest
Telemetry you must not lose (billing, alarms)1 + idempotent consumer (dedupe on message id/ts)noAt-least-once; duplicates possible
Commands1, with command id and result messagenoRetaining commands replays them on reconnect
Current state / desired config1yesNew subscribers get the latest value immediately
Online/offline status1yes (LWT also retained)Dashboards see status without waiting
  • QoS 2 is rarely worth it: more round trips and broker state, and end-to-end exactly-once still needs idempotency in your app. Prefer QoS 1 + dedupe.
  • QoS is per hop: the delivered QoS is the minimum of the publish and the subscription QoS.
  • Clear a retained message by publishing an empty payload with retain set.
  • Never retain events or commands; a device rebooting next week would execute them.

3. Device client essentials

  • LWT: set at connect: topic .../status, payload offline, QoS 1, retain. After connecting, publish online retained to the same topic.
  • Keepalive 30–60 s; the broker declares the client dead after 1.5× keepalive. Shorter keepalive costs battery on cellular/Wi-Fi sleepers.
  • Sessions: devices that must receive commands sent while offline use a persistent session (MQTT 3.1.1 clean_session=false; MQTT 5 clean_start=false + session_expiry_interval, e.g. 1 day). Fire-and-forget sensors use clean sessions.
  • MQTT 5 message expiry on commands (message_expiry_interval) prevents stale commands executing hours later.
  • Backoff: reconnect at 1 s, doubling to a cap of 60–300 s, with random jitter, so a broker restart does not get stampeded by the whole fleet at once.
  • Offline buffering: bounded ring buffer on device; drop oldest or downsample when full.
  • Time-stamp telemetry on the device (seconds since epoch from NTP/RTC), not on arrival.

Python client (paho-mqtt 2.x requires choosing the callback API version):

import json, random, ssl, time
import paho.mqtt.client as mqtt

DEVICE = "dev-a1b2c3"
BASE = f"acme/plant1/{DEVICE}"

def on_connect(client, userdata, flags, reason_code, properties):
    if reason_code.is_failure:
        print("connect failed:", reason_code)
        return
    client.publish(f"{BASE}/status", "online", qos=1, retain=True)
    client.subscribe(f"{BASE}/cmd/+", qos=1)

def on_message(client, userdata, msg):
    if len(msg.payload) > 4096:
        return
    cmd = json.loads(msg.payload)
    # validate cmd fields, execute, then publish result to f"{msg.topic}/result"

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=DEVICE, protocol=mqtt.MQTTv5)
client.username_pw_set(DEVICE, open("/etc/device/mqtt_password").read().strip())
client.tls_set(ca_certs="/etc/device/ca.pem", tls_version=ssl.PROTOCOL_TLS_CLIENT)
client.will_set(f"{BASE}/status", "offline", qos=1, retain=True)
client.reconnect_delay_set(min_delay=1, max_delay=120)
client.on_connect, client.on_message = on_connect, on_message
client.connect("broker.example.com", 8883, keepalive=60)
client.loop_start()
while True:
    client.publish(f"{BASE}/telemetry/temp", json.dumps({"v": 1, "ts": int(time.time()), "c": 21.4}), qos=0)
    time.sleep(60 + random.uniform(-2, 2))

On ESP-IDF use the esp-mqtt component (esp_mqtt_client_init with broker.address.uri set to mqtts://... and the CA cert); it reconnects automatically — set its reconnect timeout and LWT fields in the config struct.

2. Broker choice

SituationBroker
Single site, up to tens of thousands of clients, simple opsMosquitto 2.x
Clustering, rule engine, built-in dashboard, large fleetsEMQX (check the license terms of the version you deploy)
Fully managedAWS IoT Core, Azure Event Grid MQTT, HiveMQ Cloud
Home automationMosquitto add-on with Home Assistant — see home-assistant

For Mosquitto listener/TLS/password/ACL configuration and EMQX equivalents, read references/broker-config.md when setting up or hardening a broker.

4. Backend consumers

  • Scale consumers with MQTT 5 shared subscriptions: $share/ingest/acme/+/+/telemetry/#; the broker load-balances messages across the group.
  • Bridge to a durable log (Kafka, a database) quickly; MQTT brokers are not long-term storage.
  • Consumers must be idempotent under QoS 1 redelivery.

Testing commands

# watch everything for one device (as an admin user with read ACL)
mosquitto_sub -h broker.example.com -p 8883 --cafile ca.pem -u admin -P "$PW" \
  -t 'acme/plant1/dev-a1b2c3/#' -v -F '%I %t %r %q %p'

# send a command with MQTT 5 message expiry (60 s)
mosquitto_pub -h broker.example.com -p 8883 --cafile ca.pem -u ops -P "$PW" -V mqttv5 \
  -t 'acme/plant1/dev-a1b2c3/cmd/reboot' -q 1 -m '{"id":"7f3a","after_s":5}' \
  -D publish message-expiry-interval 60

# prove ACLs: a device must NOT read another device's commands
mosquitto_sub -h broker.example.com -p 8883 --cafile ca.pem -u dev-a1b2c3 -P "$DEVPW" \
  -t 'acme/plant1/dev-zzz/cmd/#' -v     # expect no messages / subscription refused

Debugging table

SymptomLikely cause
Connect/disconnect loop every few secondsDuplicate client ID; or keepalive shorter than device’s blocking time
Connection refused: not authorisedWrong credentials, or Mosquitto 2.x default of no anonymous access
Subscribed but receives nothingACL denies read (silently on Mosquitto), topic typo, leading slash
Commands executed twiceQoS 1 redelivery without idempotency (track command ids)
Old command runs after rebootRetained command or persistent session without message expiry
Dashboard shows device online when deadLWT not set or not retained; keepalive too long
Broker memory growsPersistent sessions for clients that never return; set session expiry and max_queued_messages

Verification

  1. ACL tests: each device can publish/subscribe only under its own ID; cross-device attempts fail.
  2. Kill a device’s power: its status turns offline within ~1.5× keepalive.
  3. Restart the broker: the fleet reconnects over a spread of seconds (jitter), not in one spike; persistent-session devices receive queued commands.
  4. TLS: openssl s_client -connect broker.example.com:8883 -servername broker.example.com shows a valid chain; plaintext 1883 is closed from outside.
  5. Duplicate-delivery test: replay a QoS 1 command; the device executes it once.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/broker-config.md 146 lines · 4.7 KB
# Broker configuration

Read when setting up or hardening a broker. Options below are for Mosquitto 2.x; check
`man mosquitto.conf` for your installed version before relying on less common options.

## Mosquitto 2.x defaults that surprise people

- With no `listener` configured, Mosquitto listens on localhost only.
- `allow_anonymous` defaults to `false` once you define listeners in 2.x; clients
  without credentials are refused.
- `per_listener_settings false` (default) means auth settings apply globally. Set it to
  `true` when different listeners need different auth (e.g. local plaintext for a
  bridge, TLS + passwords for devices).

## Production config

```conf
# /etc/mosquitto/conf.d/production.conf
per_listener_settings true

# Local-only plaintext for on-host services
listener 1883 127.0.0.1
allow_anonymous false
password_file /etc/mosquitto/passwd
acl_file /etc/mosquitto/acl

# Device listener: TLS + username/password
listener 8883
protocol mqtt
cafile   /etc/mosquitto/certs/ca.pem
certfile /etc/mosquitto/certs/server.pem
keyfile  /etc/mosquitto/certs/server.key
tls_version tlsv1.2
allow_anonymous false
password_file /etc/mosquitto/passwd
acl_file /etc/mosquitto/acl

persistence true
persistence_location /var/lib/mosquitto/
autosave_interval 300

max_queued_messages 1000      # per-client queue cap for offline persistent sessions
max_inflight_messages 20
message_size_limit 262144     # 256 KB; reject oversized payloads
max_keepalive 300

log_dest syslog
log_type error
log_type warning
log_type notice
connection_messages true
```

## Credentials

```bash
sudo mosquitto_passwd -c /etc/mosquitto/passwd admin     # -c creates/overwrites the file: only the first time
sudo mosquitto_passwd /etc/mosquitto/passwd dev-a1b2c3
sudo mosquitto_passwd -b /etc/mosquitto/passwd dev-a1b2c4 "$(openssl rand -base64 24)"   # scripted provisioning
sudo chown mosquitto: /etc/mosquitto/passwd && sudo chmod 600 /etc/mosquitto/passwd
sudo systemctl reload mosquitto          # SIGHUP reloads password and ACL files
```

`-c` on an existing file erases every other user — confirm before running it on a live
broker.

## ACL file

```conf
# /etc/mosquitto/acl
# Admin / backend users
user admin
topic readwrite #

user ingest
topic read acme/+/+/telemetry/#
topic read acme/+/+/status
topic read acme/+/+/state

user ops
topic write acme/+/+/cmd/#
topic write acme/+/+/config
topic read  acme/+/+/cmd/+/result

# Device rules: %u = username (must equal device id)
pattern write acme/+/%u/telemetry/#
pattern write acme/+/%u/state
pattern write acme/+/%u/status
pattern read  acme/+/%u/cmd/#
pattern write acme/+/%u/cmd/+/result
pattern read  acme/+/%u/config
```

- Mosquitto denies silently: a subscriber receives nothing rather than an error (MQTT 5
  clients may get a reason code on SUBACK). Test denials explicitly.
- `%c` substitutes the client ID; using `%u` with client ID = username enforced by
  provisioning is simpler to reason about.

## Client certificates (mutual TLS)

```conf
listener 8883
cafile   /etc/mosquitto/certs/device-ca.pem
certfile /etc/mosquitto/certs/server.pem
keyfile  /etc/mosquitto/certs/server.key
require_certificate true
use_identity_as_username true     # certificate CN becomes the username for ACL patterns
```

Issue one certificate per device with CN = device ID. Revoke by CRL (`crlfile`) or by
removing the device from ACLs. Keep the CA private key offline.

## Bridging (edge broker to cloud)

```conf
connection cloud
address broker.example.com:8883
bridge_cafile /etc/mosquitto/certs/ca.pem
remote_username site-plant1
remote_password <secret>
topic acme/plant1/+/telemetry/# out 1
topic acme/plant1/+/cmd/# in 1
cleansession false
```

## Monitoring

- `$SYS/broker/clients/connected`, `$SYS/broker/messages/received`,
  `$SYS/broker/load/messages/received/1min`, `$SYS/broker/store/messages/count`.
- Restrict `$SYS` read access to admin/monitoring users.
- Export to Prometheus with a `$SYS`-based exporter.

## EMQX equivalents

| Concern | EMQX approach |
|---|---|
| Config | `emqx.conf` (HOCON) or the dashboard (port 18083); change the default dashboard password immediately |
| Password auth | Authentication chain: built-in database, or external (PostgreSQL, Redis, HTTP, JWT) |
| ACLs | Authorization sources: built-in rules, file, database, HTTP; `${username}` / `${clientid}` placeholders |
| TLS | `listeners.ssl.default` with `ssl_options` (certfile, keyfile, cacertfile, verify) |
| Shared subscriptions | `$share/<group>/topic` (also `$queue/` legacy) |
| Routing to databases/Kafka | Rule engine + data integrations (availability depends on edition/version) |
| Clustering | Native; plan node discovery (static, DNS, k8s) |

EMQX changed its open-source licensing in recent releases; confirm the license and
feature set of the exact version before committing to it.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc