MQTT for IoT skill for Codex
Topics, QoS and ACLs that scale past ten devices. Codex designs MQTT topic trees with the device ID at a fixed level so one ACL pattern isolates every device, then chooses QoS, retain and session settings per message type. It configures Mosquitto with TLS and certificate-based ACLs, writes paho-mqtt 2.x clients that resubscribe on reconnect, and ships a script that checks topics and filters against the matching rules.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- mqtt-iot Installs to ~/.agents/skills/mqtt-iot/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install mqtt-iot The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project mqtt-iot Installs to .agents/skills/mqtt-iot/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/mqtt-iot.zipunzip -o mqtt-iot.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) mqtt-iot PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $mqtt-iot, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs and secures MQTT for IoT fleets - topic hierarchies, QoS 0/1/2 and retained messages, last will, persistent sessions and MQTT 5 features (session expiry, shared subscriptions, message expiry), Mosquitto TLS and per-device ACLs, and paho-mqtt 2.x clients that reconnect cleanly. Use when designing topics for devices, messages are lost or duplicated, devices keep kicking each other off, retained data goes stale, a broker is open to the internet without auth, or when debugging with mosquitto_sub and mosquitto_pub.
Use it when
- Designing MQTT topics, payloads and QoS for a device fleet
- Messages are lost, duplicated, or stale retained values confuse dashboards
- Devices flap between connected and disconnected
- A broker needs TLS, per-device credentials and ACLs
Use something else when
- You are building Home Assistant automations on top of MQTT entities: use home-assistant
- The transport is Bluetooth LE rather than MQTT: use ble-devices
How to check the result
Run scripts/topic_check.py over code and observed topics, prove cross-device publishes are denied, and restart the broker to confirm clients reconnect, resubscribe and report presence.
The mistake it prevents
Hard-coding one client ID in firmware: every device with that ID kicks the previous one off the broker in an endless loop.
Done well means: the topic tree encodes identity so ACLs can restrict each device to its own branch, QoS and retain flags are chosen per message type rather than globally, every connection uses TLS with per-device credentials, and clients survive broker restarts without losing or duplicating commands that matter.
Inspect first
- Read
AGENTS.md, the broker config (mosquitto.conf, EMQX/HiveMQ/AWS IoT policy) and every place the code callspublishandsubscribe. - Inventory: list each topic, publisher, subscribers, QoS, retain, payload format and rate. Most bugs show up as an inconsistent row.
- Capture real traffic before redesigning (needs network to the broker):
mosquitto_sub -h broker.local -p 8883 --cafile ca.crt \
-u ops -P "$MQTT_PASS" -t 'site/#' -v -W 600 > observed.txt
python3 scripts/topic_check.py match -f 'site/+/telemetry/#' -f 'site/+/cmd/#' \
--topics-file observed.txt
scripts/topic_check.py validates names and filters against the MQTT matching rules,
flags lint issues (leading slash, mixed case, deep trees, # at root) and lists which
observed topics each filter covers and which no filter covers. It exits 1 on any invalid
input. -v prints the topic first on each line, which is what --topics-file expects;
-W 600 stops the capture after ten minutes.
Topic design
{org}/{site}/{device_id}/telemetry/{metric} device -> cloud, QoS 0, not retained
{org}/{site}/{device_id}/state device -> cloud, QoS 1, retained
{org}/{site}/{device_id}/cmd/{name} cloud -> device, QoS 1, not retained
{org}/{site}/{device_id}/cmd/{name}/result device -> cloud, QoS 1
{org}/{site}/{device_id}/status LWT: "offline" retained, "online" on connect
- Lowercase, no leading slash, no spaces, IDs that match the client ID or certificate CN.
- Put the device ID at a fixed level so one ACL pattern covers every device.
- Do not encode values or timestamps in topic levels; they explode subscription matching and broker memory. Payload carries data.
- Version the payload (
"v": 2field or a content type property in MQTT 5), not the topic, unless the change is breaking for all consumers. - Wildcard filters starting with
+or#never match topics beginning with$($SYS,$aws), by specification.
QoS and retain decisions
| Message | QoS | Retain | Why |
|---|---|---|---|
| High-rate telemetry | 0 | No | Next sample replaces a lost one; QoS 1 costs a PUBACK per message |
| Device state / config shadow | 1 | Yes | New subscribers need the last value immediately |
| Commands (open valve, set point) | 1 + idempotency key | No | Retained commands replay on every reconnect |
| Billing or audit events | 1 + dedupe on consumer | No | QoS 2 is rarely worth its 4-packet handshake end to end |
| Online/offline | 1 | Yes (via LWT) | Dashboards need presence without waiting |
QoS 1 means at least once: consumers must tolerate duplicates. Delivered QoS is the minimum
of publish and subscribe QoS. Clear a retained message by publishing an empty retained
payload: mosquitto_pub -t 'site/a/dev1/state' -r -n. Confirm with the user before clearing
retained topics on a production broker; dashboards and devices may depend on them.
Sessions and client IDs
- Client IDs must be unique. A second connection with the same ID disconnects the first, which then reconnects and kicks the second: the classic flapping loop. Derive it from the device serial, never a constant in firmware.
- Persistent sessions (MQTT 3.1.1
clean_session=false, MQTT 5clean_start=falsewith asession_expiry_interval) queue QoS 1 messages while a device is offline. Set an expiry in MQTT 5; infinite sessions for dead devices fill broker memory. - MQTT 5 message expiry on commands stops a device from executing a stale command hours later.
- Keepalive 30-60 s for mains devices; longer for battery devices on cellular, balanced against how quickly the LWT should fire (about 1.5 times keepalive).
- Shared subscriptions (
$share/workers/site/+/telemetry/#) load-balance backend consumers; without them every consumer instance gets every message.
Broker security (Mosquitto 2.x)
# mosquitto.conf
per_listener_settings false
allow_anonymous false
listener 8883
cafile /etc/mosquitto/certs/ca.crt
certfile /etc/mosquitto/certs/server.crt
keyfile /etc/mosquitto/certs/server.key
require_certificate true
use_identity_as_username true # certificate CN becomes the username
acl_file /etc/mosquitto/acl
# /etc/mosquitto/acl
# %c = client id, %u = username (here the certificate CN)
pattern write acme/+/%u/telemetry/#
pattern readwrite acme/+/%u/state
pattern write acme/+/%u/status
pattern read acme/+/%u/cmd/#
pattern write acme/+/%u/cmd/+/result
user backend
topic read acme/#
topic write acme/+/+/cmd/#
- Mosquitto 2.0+ refuses remote connections without an explicit listener and auth; do not
“fix” that with
allow_anonymous trueon a reachable port. - Username/password devices:
mosquitto_passwd -c /etc/mosquitto/passwd dev-0001(prompts for the password;-coverwrites the file, so drop it when adding users) andpassword_fileinstead of certificates. - Plain 1883 only on localhost or an isolated network. Port 8883 with TLS everywhere else.
- Per-device credentials so one leaked device can be revoked alone. Keep private keys and
passwords out of the repo; see
secrets-management. - Validate payloads on the backend; a device that passes the ACL can still send garbage.
Client: paho-mqtt 2.x (Python)
import json, ssl, uuid
import paho.mqtt.client as mqtt
DEVICE = "dev-0001"
def on_connect(client, userdata, flags, reason_code, properties):
if reason_code.is_failure:
print("connect failed:", reason_code)
return
client.publish(f"acme/s1/{DEVICE}/status", "online", qos=1, retain=True)
client.subscribe(f"acme/s1/{DEVICE}/cmd/#", qos=1) # resubscribe on every connect
def on_message(client, userdata, msg):
cmd = json.loads(msg.payload)
if seen_before(cmd["id"]): # QoS 1 duplicates happen
return
handle(msg.topic, cmd)
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=DEVICE,
protocol=mqtt.MQTTv5)
client.tls_set(ca_certs="ca.crt", certfile=f"{DEVICE}.crt", keyfile=f"{DEVICE}.key",
tls_version=ssl.PROTOCOL_TLS_CLIENT)
client.will_set(f"acme/s1/{DEVICE}/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_forever(retry_first_connection=True)
paho-mqtt 2.0 made the callback API version argument mandatory; 1.x examples with
on_connect(client, userdata, flags, rc) fail. Subscribe inside on_connect, never only
once at start, or a reconnect without a persistent session leaves the client deaf.
On microcontrollers the same rules apply with ESP-MQTT, Zephyr’s MQTT client or umqtt.
Gotchas
- Large payloads: brokers cap message size (Mosquitto
message_size_limit, AWS IoT 128 KB). Send files over HTTPS and publish a reference. - Ordering is guaranteed only per topic per QoS level from one publisher.
- Bridges and wildcard re-publishing create loops; set bridge topic directions explicitly.
- A device that publishes QoS 1 faster than the link drains fills its in-flight window and stalls; rate-limit or drop to QoS 0 for telemetry.
Verification
-
topic_check.pypasses on every topic and filter in code and ACLs - With device A’s credentials, publishing to device B’s topics is refused (test it)
- Broker restart: clients reconnect, resubscribe and presence returns to “online”
- Kill a device’s network: LWT “offline” appears within about 1.5 times keepalive
- Duplicate a command message: the device executes it once
Report
**Broker:** Mosquitto 2.0.20, TLS 8883 with client certificates, ACL by certificate CN
**Topic tree:** acme/{site}/{device}/{telemetry|state|status|cmd}; 9 topics, 3 filters
**Changes:** commands no longer retained; telemetry QoS 1 -> 0; LWT added; session expiry 1 h
**Tests:** cross-device publish denied; broker restart recovered in 4 s; LWT fired at 82 s What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "MQTT for IoT"
short_description: "Topics, QoS and ACLs that scale past ten devices"
default_prompt: "$mqtt-iot Design the MQTT topic structure and ACLs for 500 sensor devices" scripts/topic_check.py
#!/usr/bin/env python3
"""Validate MQTT topic names and filters, and show which filters match which topics.
Implements the MQTT 3.1.1 / 5.0 matching rules, including:
- '+' matches exactly one level, '#' matches the parent level and everything below
- filters starting with a wildcard do not match topics starting with '$'
- '$share/<group>/<filter>' shared subscriptions are matched on <filter>
Usage:
python3 topic_check.py validate 'site/+/telemetry/#' 'site/dev-01/telemetry/temp'
python3 topic_check.py match -f 'site/+/telemetry/#' -f '#' -t 'site/a/telemetry/t' -t '$SYS/x'
python3 topic_check.py match -f ... --topics-file observed_topics.txt
Exit code 0 if every input is valid, 1 if any is invalid, 2 on usage errors.
Lint warnings (leading slash, spaces, uppercase mix, deep trees) do not fail the run.
"""
import argparse
import sys
MAX_TOPIC_BYTES = 65535
def validate(s, is_filter):
errors, warnings = [], []
if not s:
return ["empty string"], warnings
if len(s.encode("utf-8")) > MAX_TOPIC_BYTES:
errors.append("longer than 65535 bytes")
if "\x00" in s:
errors.append("contains NUL")
target = s
if is_filter and s.startswith("$share/"):
parts = s.split("/", 2)
if len(parts) < 3 or not parts[1] or "+" in parts[1] or "#" in parts[1]:
errors.append("shared subscription needs $share/<group>/<filter>")
return errors, warnings
target = parts[2]
levels = target.split("/")
for i, lvl in enumerate(levels):
if not is_filter and ("+" in lvl or "#" in lvl):
errors.append("wildcards are not allowed in topic names (publish)")
break
if is_filter:
if "#" in lvl and (lvl != "#" or i != len(levels) - 1):
errors.append("'#' must be the last level and occupy it alone")
if "+" in lvl and lvl != "+":
errors.append(f"'+' must occupy a whole level (level {i + 1}: {lvl!r})")
if target.startswith("/"):
warnings.append("leading '/' creates an empty first level")
if target.endswith("/") and target != "/":
warnings.append("trailing '/' creates an empty last level")
if "//" in target:
warnings.append("'//' creates an empty level")
if " " in target:
warnings.append("contains spaces")
if len(levels) > 8:
warnings.append(f"{len(levels)} levels; deep trees are hard to secure with ACLs")
if is_filter and target == "#":
warnings.append("subscribes to everything; only for debugging or bridges")
mixed = any(c.isupper() for c in target) and any(c.islower() for c in target)
if mixed and not target.startswith("$"):
warnings.append("mixed case; topics are case-sensitive, prefer lowercase")
return errors, warnings
def matches(flt, topic):
if flt.startswith("$share/"):
flt = flt.split("/", 2)[2]
f_levels, t_levels = flt.split("/"), topic.split("/")
if topic.startswith("$") and f_levels[0] in ("+", "#"):
return False
for i, f in enumerate(f_levels):
if f == "#":
return True
if i >= len(t_levels):
return False
if f != "+" and f != t_levels[i]:
return False
return len(f_levels) == len(t_levels)
def looks_like_filter(s):
return s.startswith("$share/") or "+" in s or "#" in s
def report(s, is_filter):
errors, warnings = validate(s, is_filter)
kind = "filter" if is_filter else "topic"
status = "INVALID" if errors else "ok"
print(f"{status:<8} {kind:<6} {s}")
for e in errors:
print(f" error: {e}")
for w in warnings:
print(f" warn: {w}")
return not errors
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
v = sub.add_parser("validate", help="validate names and filters (filter if it has wildcards)")
v.add_argument("items", nargs="+")
m = sub.add_parser("match", help="match filters against topics")
m.add_argument("-f", "--filter", action="append", default=[], required=True)
m.add_argument("-t", "--topic", action="append", default=[])
m.add_argument("--topics-file", help="one topic per line, e.g. captured with mosquitto_sub -v")
args = ap.parse_args()
ok = True
if args.cmd == "validate":
for s in args.items:
ok &= report(s, looks_like_filter(s))
return 0 if ok else 1
topics = list(args.topic)
if args.topics_file:
with open(args.topics_file, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if line:
topics.append(line.split(" ", 1)[0]) # tolerate 'topic payload' lines
if not topics:
print("error: give at least one -t or --topics-file", file=sys.stderr)
return 2
for f in args.filter:
ok &= report(f, True)
for t in topics:
ok &= report(t, False)
print()
for f in args.filter:
hits = [t for t in topics if matches(f, t)]
print(f"{f} -> {len(hits)} of {len(topics)} topics")
for t in hits:
print(f" {t}")
unmatched = [t for t in topics if not any(matches(f, t) for f in args.filter)]
if unmatched:
print("\nnot covered by any filter:")
for t in unmatched:
print(f" {t}")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.