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.

  • Embedded & IoT
  • $mqtt-iot
  • 185 lines
  • 3 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- mqtt-iot

Installs to ~/.agents/skills/mqtt-iot/ for every project on this machine.

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.

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

--- name: mqtt-iot description: Designs and secures MQTT for IoT fleets - topic hierarchies, QoS 0/1/2 and retained messages, last will, persistent sessions and MQTT 5 features… license: MIT ---

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

  1. Read AGENTS.md, the broker config (mosquitto.conf, EMQX/HiveMQ/AWS IoT policy) and every place the code calls publish and subscribe.
  2. Inventory: list each topic, publisher, subscribers, QoS, retain, payload format and rate. Most bugs show up as an inconsistent row.
  3. 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": 2 field 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

MessageQoSRetainWhy
High-rate telemetry0NoNext sample replaces a lost one; QoS 1 costs a PUBACK per message
Device state / config shadow1YesNew subscribers need the last value immediately
Commands (open valve, set point)1 + idempotency keyNoRetained commands replay on every reconnect
Billing or audit events1 + dedupe on consumerNoQoS 2 is rarely worth its 4-packet handshake end to end
Online/offline1Yes (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 5 clean_start=false with a session_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 true on a reachable port.
  • Username/password devices: mosquitto_passwd -c /etc/mosquitto/passwd dev-0001 (prompts for the password; -c overwrites the file, so drop it when adding users) and password_file instead 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.py passes 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.yamlgenerated from this page

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.py144 lines, 5.4 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem