Table of Contents
Introduction
GenUI for IoT is a series about generative UI: apps where the language model does not just answer in text, but composes the screen itself from components the app already knows how to draw. We build it into a Flutter app that watches a fleet of connected sensors, one design question per part.
In Part 1, the agent composed a fleet’s home screen from facts and never guessed at causes. That kept it honest, and it stopped right where users start asking: why is this sensor missing data?
Part 2 is the agent that answers it without making things up: it investigates with tools, shows what it found, and labels what it infers as a hypothesis.
Not every tap is a model call
Before writing any prompt, sort every interaction into one of three kinds. An agent app that sends every tap to the model is slow, expensive, and unpredictable.
| Interaction | Goes to | Example |
|---|---|---|
| Agent action | The model, as a new turn | The “Why is this sensor missing data?” button, a tapped follow-up chip. |
| Local action | App state, no model | Changing a chart’s time range, filtering the device list |
| Device operation | A local queue, behind an explicit button | Ping a device, request a reading |
For device operations, the user is the one who runs them. The model can suggest pinging a device by composing a card with a button on it, but it cannot ping the device itself: a person reads the card and taps the button. That keeps every action with side effects behind a human, and keeps the model out of anything that does not need judgment.
Inside each answer there is a second split: code supplies the facts, the model supplies the judgment.
- Code decides the facts: every count, timestamp and gap, whether devices share a gap, each device’s status, which device is being asked about, and whether a tool call is valid.
- The model decides the reasoning: which tools to call and in what order, when it has enough, which facts deserve a card, what they suggest, and which follow-ups to offer.
The model never writes a measurement. Components that show readings or events get only a device id and load the data themselves, so the numbers on screen never pass through the model.
Tools return evidence, never a cause
The tempting tool is diagnoseSensor. It would return a ready-made answer, and it would move the whole investigation into code, leaving the model to read the answer out loud. We built the opposite: five small tools, each answering one question with a deterministic result, none of them allowed to definitively name a cause.
| Tool | Question it answers | Returns | Never returns |
|---|---|---|---|
getRecordGaps | How much is missing, and where? | Expected and received counts, each gap with its span, the device’s gateway, neighbours and battery status | Why; the readings themselves |
compareRecordGaps | Is this isolated or shared? | Each device’s gaps, plus the stretches several devices share | The word “cause” |
getGatewayEvents | Did the gateway or the sensor lose its connection? | Every connect and disconnect recorded, for the gateway and for each device | Whether an event explains a gap |
getSyncHistory | Did the missing readings arrive later? | Readings that arrived late, grouped by when they arrived | Whether readings still missing will arrive |
getBatteryHistory | Was the battery running down? | Its level at the start, now and at its lowest, when it dropped below the low mark, and one reading per hour | Whether the battery explains a gap |
Two rules shaped these tools:
- Return enough to decide the next call, not everything.
getRecordGapsreturns the ids of the neighbouring devices, so the model can choose to compare. It does not return their gaps: that iscompareRecordGaps’ job, and many investigations never need it. - Count model calls, not characters. Each tool result is a few hundred characters, tiny next to the 14k-token system prompt sent with every call. What costs time is the number of model calls, so the prompt carries one rule: do not call a tool whose result cannot change what you say.
The logic behind the hypotheses
The investigation has a written plan. The prompt tells the model to start with getRecordGaps, then gives one rule per tool, saying when its result can change the answer:
1. Call getRecordGaps for the device.
2. Decide what else you need:
other devices on the same gateway → compareRecordGaps
a shared gap, or the device offline → getGatewayEvents
a delivered batch, or a reconnect → getSyncHistory
low battery, or short gaps that keep
coming back while neighbours are fine → getBatteryHistory
3. Do not call a tool whose result cannot change what you say.
What the prompt does not fix is the order after the first call, or when to stop. The model reads each result and decides. The tree below is what it did with these rules in our runs, taken from each run’s trace, with the branch each of the five cases took.
The five cases are five data sets where sensor-42 is missing data, each with a different story behind it:
- A: one sensor alone
- B: a gateway outage
- C: a delayed sync
- D: a low battery
- E: a battery that ran out.
One question, five answers below shows what the agent said in each.
Every question in the tree is answered by a tool, with exact data. Only the dashed boxes come from the model, and each reaches the screen as a hypothesis, with the facts behind it and the check that would rule it out.
A guess has to look like a guess
Part 1’s rule still holds: the agent shows facts and never states a cause as one. Part 2 builds on it. When the user asks why data is missing, the agent can now add its interpretation on top of those facts, as long as the screen never lets it pass for one.
The model shows every finding as an EvidenceCard, and it must tell the card which kind it is:
- A fact is something a tool returned, quoted back:
“Gap 08:12 to 09:42, 3 readings”, “Gateway disconnected 07:58”. - A hypothesis is the model’s reading of those facts. The card draws it differently: italic, its own border, a HYPOTHESIS label.
It can offer one follow-up, the check that would confirm or rule it out, and tapping it becomes the user’s next question.
The schema only allows fact or hypothesis, but the model writes the surface as text, so nothing forces it. If it ever sends a label the app does not recognise, the card falls back to a hypothesis, never to a fact.
What the fallback cannot catch is a guess labelled fact: a valid value with the wrong label. Today the model that writes the card also labels it.
A typed classifier such as Jev, which picks one option from a fixed set and says how confident it is, could check that label on its own: read the tool results and the card, and answer “fact” or “hypothesis”. We have not tried it yet.
The prompt adds three rules for the hypothesis card:
- Only one per answer. The model has to pick its best reading, not list every possibility.
- It lists the facts it rests on, so the user can judge it.
- No words that sound certain: “confirmed”, “root cause”, “failure”.
In short: the model does not mark a guess with words like “probably”. It sets a field, and the app draws facts and guesses differently, so the user can tell them apart at a glance.
One question, five answers
We built five data sets where sensor-42 is missing data and asked the same question each time.
The prompt never names the case, so any difference in the answers comes from the tools.
| Case | Scenario | What the data holds | Tools called | Its hypothesis |
|---|---|---|---|---|
| A | One sensor | Two gaps on sensor-42 only, no connection drops | gaps, compare, events, sync | “Isolated connectivity or device issue” |
| B | Gateway | One gap shared by three devices, during a gateway outage | gaps, compare, events | “Gateway outage likely caused the missing data” |
| C | Delayed sync | An open gap; the sensor lost its connection but stores its readings | gaps, compare, events | “Local communication interruption”, plus an upload action |
| D | Low battery | Short, growing gaps; battery down from 30% to 6% | gaps, compare, battery | “Low battery likely causing intermittent connectivity” |
| E | Battery ran out | Like delayed sync, but the battery fell to 1% and nothing arrives | gaps, compare, events, battery | “Battery exhaustion likely caused disconnect”, no upload offered |
In the delayed sync case the agent cannot yet tell whether the readings are lost or late, and says so. It offers an action to upload the stored readings over Bluetooth, for someone near the sensor, and a follow-up chip to check later. Either way, getSyncHistory then shows the readings arrived, and the agent recommends no action. When the battery ran out it offers no upload: there is nothing to connect to.
No component knows any of these diagnoses: all five answers use the same components, and the conclusion always sits in a card labelled as a hypothesis.
What we would keep
Part 1 asked four design questions. Part 2 is our answer to them, in code:
- What should become a tool? An investigative capability with a deterministic answer.
getRecordGaps, notdiagnoseSensor. - How much context should a tool return? Enough to decide the next call. Progressive, not exhaustive.
- Which decisions belong in code? Every count, timestamp, correlation and status. The model keeps which evidence to fetch, what it suggests, and how to show it.
- How specific should a component be? Specific in what it loads, neutral in what it concludes. The diagnosis lives in a card the model fills, labelled as its own.
None of this needed a smarter model. It needed clearer boundaries: tools that return evidence, components that paint a guess as a guess, and a human in front of every action. Next in the series: streaming under the hood, and what it is like to build all of this on a library that is still pre-1.0.
Related Topics
About the Author
Mario Corzo
Senior Mobile Developer
Senior Mobile Developer with 10+ years of experience building cross-platform applications across mobile, web, and gaming. Specialized in Flutter, BLE-connected mobile apps, and over-the-air firmware update flows for IoT systems. At Croxel, Mario bridges firmware and user experience to deliver intuitive interfaces for connected devices.
Mario Corzo has written 3 articles for Croxel Insights.