← Projects

Project

Letting an LLM talk to IoT telemetry

An afternoon prototype: MQTT, TimescaleDB and an MCP server in Go. What I decided, and what I learned.

The question

Most of my career has been about getting data from devices into systems people can use: MQTT ingestion, time-series storage, APIs for third parties. LLMs add a new kind of consumer to that chain, one that asks questions in natural language and is not deterministic.

I wanted to find out, hands-on, what it takes for that consumer to answer operational questions reliably. Not a generic chatbot: a small, real system with the same shape as the platforms I have designed.

What I built

Four simulated temperature probes publish readings over MQTT. An ingestor in Go stores them in TimescaleDB and detects alarms. An MCP server, also in Go, exposes two tools to Claude Desktop: one for alarm episodes and one for temperature statistics.

MQTT QoS 1

sensores/+/temperatura

stdio

Simulators
sensor1..4

Mosquitto

Ingestor
Go

TimescaleDB

MCP server
Go, read-only

Claude Desktop

MQTT QoS 1

sensores/+/temperatura

stdio

Simulators
sensor1..4

Mosquitto

Ingestor
Go

TimescaleDB

MCP server
Go, read-only

Claude Desktop

Screenshot of Claude Desktop using the MCP server tools
Querying Claude with natural language, which uses the tool to retrieve data from the IoT server.

Decisions on the data path

Device timestamps, not arrival time. The simulator behaves like a data logger: it buffers readings and dumps them in batches when it regains connectivity, each reading with its own timestamp. That is how real field devices work, and it lets me generate a week of history in seconds.

Alarm state lives on the device. The usual way to find an open alarm is to search the history for episodes without an end date. On a production platform with around 32,000 devices and many event types, that query became a real cost for us. Here the device record holds the current alarm state, and every state change and alarm write happen in the same transaction, so the two can never disagree.

Hysteresis. An alarm opens above the threshold and closes only after the temperature drops a margin below it. Without that, a noisy signal hovering at the threshold opens and closes alarms every few seconds.

Idempotent ingestion. MQTT QoS 1 can deliver a message twice. A uniqueness constraint on device and timestamp absorbs duplicates without extra logic.

Designing tools for a non-deterministic consumer

This was the core of the exploration, and it felt familiar: it is product ownership of an API. The difference is the consumer. A developer reads the documentation once and writes deterministic code that fails loudly when it is wrong. An LLM reads the tool descriptions in every conversation, decides at runtime, can decide differently each time, and when it misunderstands, it does not fail: it gives a plausible answer.

That shaped every decision:

  • Design for the user’s questions, not for the tables. Two tools, not one per table. There is deliberately no “give me the raw time series” tool. Each consumer gets the interface that fits it: curated tools for conversation, SQL or ODBC for BI, a stable API for integrations.
  • Return aggregates. A week of readings is ~10,000 rows per sensor. Statistics bucketed with TimescaleDB’s time_bucket are enough for answers and even for charts, at a fraction of the tokens.
  • Pre-compute what models get wrong. Alarm durations come back already calculated. Date arithmetic is a known weak spot.
  • Give the model a clock. Every response carries the server time, so “yesterday” and “this week” have a reference.
  • Never truncate silently. Large results return the real total and a hint to narrow the filters, so “how many alarms this month?” still has a correct answer.
  • Make errors teach. An unknown sensor id returns the list of valid ones, and the model corrects itself.
  • Define semantics the way people ask. An alarm that started on Sunday and ended on Tuesday is part of “this week’s alarms”. The time filter uses overlap, not containment.
  • Least privilege. The MCP server reads the database with a read-only user.

What I found

Two well-designed tools go a long way. Asked about an alarm, Claude fetched the alarm, requested 24 hours of bucketed statistics around it and drew a chart with the threshold and the alarm window marked. No chart tool, no raw data.

A beautiful answer can still be wrong. The same response introduced the chart as covering “from 05:06 to 29:06 UTC on 19 September”. The model added 24 hours to the clock without rolling over the date. Giving it a clock reduces this kind of error; it does not remove it. And the more convincing the output looks, the easier the mistake is to miss. In a later run of a similar question, the dates came out right. That is the point: a test that passes once proves little when the consumer is not deterministic.

From prototype to production

An afternoon prototype rests on assumptions a real platform cannot make. These are the ones I would change first:

  • Parallel ingestion. With serverless ingestion, such as cloud IoT rules invoking functions, the single writer and the ordering of messages disappear. A log partitioned by device (Kinesis in the cloud; Kafka, Redpanda or NATS JetStream on-premises) restores ordering per device while keeping parallelism across devices, and lets storage, alarms and analytics consume the same stream independently.
  • End-to-end delivery. MQTT guarantees delivery hop by hop, not end to end. Rather than requiring every component to be perfect, the device keeps each reading until the backend confirms it is stored, using sequence numbers and an application-level acknowledgement. Data loss stops being a matter of luck and becomes a sizing question: how many days of backend outage the device buffer can absorb.
  • Remote, multi-tenant MCP. HTTP transport with OAuth, each token scoping the tools to its tenant’s devices.

How I built it

I used Claude Code as a coding assistant. I owned the architecture, the alarm logic, the tool design and the evaluation; the assistant accelerated the plumbing. In a project about exploring AI, that division of labour was part of the experiment.

View the code on GitHub →