An LLM agent for IoT devices can answer questions about equipment, spot problems and carry out routine actions, but only if it is designed so that a wrong guess can never become a dangerous command. This guide walks through a production-minded architecture where the agent reads device state from MQTT, acts only through narrow, schema-validated tools, and routes risky actions through human approval before anything reaches a device.
What an IoT Agent Actually Does
A language model on its own only produces text. It becomes an agent when you give it tools: functions it can ask your code to run, such as "read the latest temperature of boiler 2" or "set fan speed on AHU-3". The loop looks like this:
- A user or a trigger (an alert, a schedule) gives the agent a goal.
- The model decides which tool to call and with which arguments.
- Your code executes the tool and returns the result.
- The model reasons over the result and either calls another tool or answers.
In IoT, the tools touch the physical world. That changes the design priorities completely: correctness and containment matter far more than cleverness. A chatbot that hallucinates gives a wrong answer; an agent that hallucinates a setpoint can overheat a process.
Reference Architecture
The architecture separates what the model may suggest from what the system will do.
| Component | Responsibility | Talks to |
|---|---|---|
| Device fleet | Publishes telemetry and state, executes commands, acknowledges | MQTT broker |
| MQTT broker | Routes telemetry, commands and acknowledgements | Everyone |
| State cache | Subscribes to state topics and keeps the latest value per device | Broker, agent tools |
| Agent runtime | Runs the LLM loop and exposes a small set of tools | LLM, state cache, policy gate |
| Policy gate | Validates every proposed command against schema, limits and permissions | Agent runtime, approval service |
| Approval service | Asks a human to approve risky actions | Operators via dashboard or chat |
| Audit log | Records prompts, tool calls, decisions and device acknowledgements | Everything |
The broker is the backbone. Devices never talk to the agent directly, and the agent never holds a socket to a device. Everything flows through topics, which means you can observe, replay and restrict every interaction. If you want a refresher on what an MQTT broker does, it helps to picture it as the switchboard that every message must pass through.
Topic Layout
A clear topic structure makes both permissions and reasoning easier:
plant1/hvac/ahu-3/state retained, latest device state (JSON)
plant1/hvac/ahu-3/telemetry periodic sensor readings
plant1/hvac/ahu-3/cmd commands addressed to the device
plant1/hvac/ahu-3/cmd/ack device acknowledgements with result codes
agent/approvals/request pending approvals for humans
agent/approvals/decision approve or reject decisions
agent/audit every tool call and outcome
Retained state topics are especially valuable: when the agent's state cache starts, the broker immediately delivers the latest state of every device, so the agent never reasons over an empty world.
Designing the Tools
The single most effective safety measure is a small, boring tool surface. Do not give the model a generic publish(topic, payload) tool. Give it intent-level tools with tightly constrained parameters:
{
"name": "set_fan_speed",
"description": "Set the supply fan speed of an air handling unit. Speed is a percentage.",
"parameters": {
"type": "object",
"properties": {
"device_id": { "type": "string", "enum": ["ahu-1", "ahu-2", "ahu-3"] },
"speed_pct": { "type": "integer", "minimum": 20, "maximum": 90 },
"reason": { "type": "string", "maxLength": 200 }
},
"required": ["device_id", "speed_pct", "reason"],
"additionalProperties": false
}
}
Note the enumerated device IDs, the integer range that matches what the equipment can safely do, and the required reason, which ends up in the audit log. Read tools follow the same principle: get_device_state(device_id) and get_recent_readings(device_id, metric, minutes) are better than handing the model raw topic access. Our tutorial on LLM function calling for IoT shows how to define and validate these schemas end to end.
The Policy Gate
Schemas sent to the model are a hint, not a guarantee. Models can and do emit arguments outside the schema, so every tool call must be validated again in your own code before anything is published. The gate below classifies each proposed command as rejected, auto-approved or needing human approval.
import json
import time
import uuid
from dataclasses import dataclass
LIMITS = {
"set_fan_speed": {"devices": {"ahu-1", "ahu-2", "ahu-3"}, "min": 20, "max": 90,
"auto_max_step": 10},
}
@dataclass
class Decision:
status: str # "rejected", "auto", "needs_approval"
reason: str
command: dict | None = None
def evaluate(tool_name: str, args: dict, current_state: dict) -> Decision:
rule = LIMITS.get(tool_name)
if rule is None:
return Decision("rejected", f"unknown tool {tool_name}")
device = args.get("device_id")
speed = args.get("speed_pct")
if device not in rule["devices"]:
return Decision("rejected", f"device {device!r} not allowed")
if not isinstance(speed, int) or not rule["min"] <= speed <= rule["max"]:
return Decision("rejected", f"speed {speed!r} out of range")
command = {
"id": str(uuid.uuid4()),
"action": "set_fan_speed",
"device": device,
"value": speed,
"reason": str(args.get("reason", ""))[:200],
"issuedAt": int(time.time() * 1000),
}
current = current_state.get(device, {}).get("fan_speed_pct")
if current is None:
return Decision("needs_approval", "current state unknown", command)
if abs(speed - current) <= rule["auto_max_step"]:
return Decision("auto", "small change within policy", command)
return Decision("needs_approval", f"change of {abs(speed - current)} points exceeds auto limit", command)
def dispatch(client, decision: Decision):
audit = {"decision": decision.status, "reason": decision.reason, "command": decision.command}
client.publish("agent/audit", json.dumps(audit), qos=1)
if decision.status == "auto":
topic = f"plant1/hvac/{decision.command['device']}/cmd"
client.publish(topic, json.dumps(decision.command), qos=1)
elif decision.status == "needs_approval":
client.publish("agent/approvals/request", json.dumps(decision.command), qos=1, retain=False)
return audit
The gate result is returned to the model as the tool output, so the model can tell the user "this change needs operator approval" instead of claiming it has already happened.
Human Approval Over MQTT
The approval service subscribes to agent/approvals/request, shows the proposed command with its reason and the current device state to an operator, and publishes the decision. A small service listening to agent/approvals/decision forwards approved commands to the device topic, and only that service holds credentials allowed to publish there. Pending approvals should expire: a request to change a fan speed based on conditions an hour old is no longer valid. MQTT 5 message expiry is a natural fit for this, because an unclaimed request simply disappears from the broker after its lifetime.
Closing the Loop With Acknowledgements
An agent must never assume a command worked. Devices publish to .../cmd/ack with the command ID and a result such as applied, rejected_local_limit or device_busy. The agent's tool waits for the matching acknowledgement with a timeout and reports the outcome truthfully. Devices should also enforce their own limits: the policy gate is a second line of defense, not the first. A device that refuses an unsafe value on its own protects you even from bugs in the gate.
Safety Checklist for an LLM Agent for IoT Devices
- Intent-level tools only; no raw publish or shell tools.
- Strict schemas with enums, ranges and
additionalProperties: false. - Independent validation in code before any publish.
- Tiered autonomy: read freely, small changes automatically, large or unusual changes with human approval, safety-critical systems never.
- Separate credentials: the agent can read state and request actions; only the dispatcher can publish commands.
- Device-side limits and acknowledgements with result codes.
- Rate limits on commands per device and per time window.
- Full audit trail of prompts, tool calls, decisions and acknowledgements.
- Prompt injection awareness: text fields from devices or tickets are data, never instructions.
Item 9 deserves emphasis. If a device name, an alarm message or a maintenance note contains text like "ignore previous instructions", a naive agent may follow it. Keep such fields clearly marked as data in the prompt and, more importantly, rely on the policy gate so that even a manipulated model cannot exceed its permissions. We cover this threat model in depth in safe AI control of IoT devices.
Where the Broker Fits in Production
The agent pattern adds more clients and more traffic types to your broker: state caches, approval services, audit consumers and dashboards. Running that on a fully managed MQTT service means TLS, authentication and scaling are handled for you. ConnectMQ binds each client ID to its issued credentials, so you can give the agent runtime and the command dispatcher separate identities and see each one in the portal. Since the platform does not offer per-topic access rules, enforce the read/act split in your services and keep dispatcher credentials off the agent host.
For prototyping, run the whole loop against the public MQTT test broker with simulated devices and a unique topic prefix such as connectmq/demo/c42b7e/..., then move to private credentials before connecting real equipment. If you prefer to keep the model on premises, see how to run a local LLM with Ollama and MQTT, and for the wider landscape of AI on connected devices, read our AI and IoT guide.
Get Started with ConnectMQ
Give your agent a dependable message backbone. ConnectMQ provides MQTT 3.1.1 and 5.0 with TLS, WebSockets, retained messages and message expiry. Create a free Serverless broker, no credit card required, and start building your agent loop with simulated devices today.
Frequently Asked Questions
Should an LLM agent ever control safety-critical equipment?
No. Safety functions belong in certified safety systems and local interlocks. An agent can recommend actions or adjust non-critical setpoints within tight limits, but it must not be part of a safety chain.
Why not let the model publish MQTT messages directly?
A generic publish tool lets the model write to any topic with any payload, which makes validation nearly impossible. Intent-level tools with strict schemas keep the action space small and checkable.
How does the agent know the current device state?
A state cache subscribes to retained state topics and keeps the latest value per device. The agent queries it through read tools rather than subscribing itself.
What happens if the device rejects a command?
The device publishes an acknowledgement with a reason code, and the agent reports that result to the user instead of assuming success.



