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.
- name
mqtt-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
- Browser or app realtime messaging over WebSockets — use realtime-websockets
- Home Assistant automations that consume MQTT entities — use home-assistant
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.
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
| Message | QoS | Retain | Why |
|---|---|---|---|
| Frequent telemetry, next sample supersedes | 0 | no | Loss tolerable, cheapest |
| Telemetry you must not lose (billing, alarms) | 1 + idempotent consumer (dedupe on message id/ts) | no | At-least-once; duplicates possible |
| Commands | 1, with command id and result message | no | Retaining commands replays them on reconnect |
| Current state / desired config | 1 | yes | New subscribers get the latest value immediately |
| Online/offline status | 1 | yes (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, payloadoffline, QoS 1, retain. After connecting, publishonlineretained 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 5clean_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
| Situation | Broker |
|---|---|
| Single site, up to tens of thousands of clients, simple ops | Mosquitto 2.x |
| Clustering, rule engine, built-in dashboard, large fleets | EMQX (check the license terms of the version you deploy) |
| Fully managed | AWS IoT Core, Azure Event Grid MQTT, HiveMQ Cloud |
| Home automation | Mosquitto 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
| Symptom | Likely cause |
|---|---|
| Connect/disconnect loop every few seconds | Duplicate client ID; or keepalive shorter than device’s blocking time |
Connection refused: not authorised | Wrong credentials, or Mosquitto 2.x default of no anonymous access |
| Subscribed but receives nothing | ACL denies read (silently on Mosquitto), topic typo, leading slash |
| Commands executed twice | QoS 1 redelivery without idempotency (track command ids) |
| Old command runs after reboot | Retained command or persistent session without message expiry |
| Dashboard shows device online when dead | LWT not set or not retained; keepalive too long |
| Broker memory grows | Persistent sessions for clients that never return; set session expiry and max_queued_messages |
Verification
- ACL tests: each device can publish/subscribe only under its own ID; cross-device attempts fail.
- Kill a device’s power: its
statusturnsofflinewithin ~1.5× keepalive. - Restart the broker: the fleet reconnects over a spread of seconds (jitter), not in one spike; persistent-session devices receive queued commands.
- TLS:
openssl s_client -connect broker.example.com:8883 -servername broker.example.comshows a valid chain; plaintext 1883 is closed from outside. - 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
# 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.