The first version of my generative dashboard took a median of fifty-four seconds to answer a question. Ask it what a gas turbine had been doing for the last hundred hours and it gathered the data correctly, reasoned about it correctly, and then wrote a dashboard so slowly that the person watching would have opened the old static screen in another tab and forgotten the new one existed.
If you lead an engineering or product team that has seen a GenUI demo and then watched it stall in the latency review, this is about what moved mine. It was not a faster model.
The bill was structural, not tunable
Fifty-four seconds is the frontier-model number. The fastest small model I had access to still took just under twenty seconds. A control room operator would normally tab through several windows and applications to gather the same information, so we had already improved on that by orders of magnitude, and watching a spinner for twenty or thirty seconds still felt unacceptable. Every one of those questions also costs tokens in and out, and the cost line scales with how much people use the thing, which is the opposite of what you want from a feature that is supposed to get better with adoption.
There was a second problem I was paying attention to alongside the timing, and that was consistency. When results did arrive they were not always the same between generations. Small differences, but enough that reviewing them took cognitive load, working out what was what and how to act on it.
The cost side is easy to see on a napkin. Every interaction has a distinct cost, and every turn accumulates token cost. In a consumer-facing product, users can unknowingly drive that cost on what amount to wasted turns. To justify it you end up reaching for offsets like call-center deflection, which sit in a different part of the organization and are harder to model.
I tried the obvious things first. Pre-caching and pre-generating the common use cases. That bought time on the six storyline questions I already knew about and added real complexity, both in the system and in testing it, and it did nothing for the questions that matter, because the entire point of a generative interface is answering the question nobody wrote a screen for. None of it changed the order of magnitude.
The order of magnitude was stuck because of what I was asking the model to do.
Most of the work was deciding, and I was paying a writer to do it
Honestly, the rule came before the insight. When I started I wanted the model to never write a number. Every value on screen had to come from the historian through a data path. That was the trust. Show a plant operator one made-up bearing reading and you are done. What I did not clock at the time was how little that rule left for the model to do.
Follow one question through the system. Before anything gets composed, something has to work out what kind of dashboard is being asked for. Which machines are named. Which subsystems are relevant. How far back to look. Whether the event log matters. Whether service history matters. How much judgment the answer requires. Whether the question is even about plant data at all, or about the tool itself.
Almost none of that is generation. It is classification. And I was paying a frontier model to classify by generating text about its classification, one token at a time, at frontier latency.
The lightbulb was zero tool calls. I had put a router in front of the model call, fetching the data before any model ran, and when I watched the queries go through I noticed the model was making no tool calls at all. It was retyping data I had handed it into a JSON shape I had also handed it. What was I paying the model to do? The numbers were already there. The model was not writing a dashboard. It was choosing what went in one. Picking tags, a time window, the right chart, and what goes on top. That is a much smaller job than the one I started with.
So I started peeling back what the model was actually being asked to do. Do not compute. Do not count. Do not format. Pull the decisions out of the composer and hand them to something built for decisions.
Putting a decision model in front
TypeSafe’s Jev makes that practical. It is what they call a System One model. You hand it a state and a set of typed questions, and it hands back typed answers with confidence scores. It generates no text. It never sees a measured value. Every question in a request is evaluated in parallel, so asking a hundred costs about what asking one costs.
In my build, every question now goes to Jev before any language model runs. One call, around a hundred atomic questions on the default plant. One yes or no per machine, so a roster of seventy-six machines is seventy-six questions. Is this a trend? Is it the right asset? Is it vibration? How far back? The answers come back in about half a second. Code combines them into a route, runs the route’s queries, and hands the composer a prelude that already quotes the results. Most questions now compose in a single turn with no tool calls at all.
That, together with moving the composer from a tool call to a plain text block, was most of the win, even with a language model still writing the dashboard. My fastest configuration dropped from just under twenty seconds to a median of about six. The two changes shipped a day apart and I never measured them separately.
The pure decision path is fast enough that I considered slowing the processing indicator down artificially so people could see the thing working.
Then I took the language model out of composing entirely
The second call is the interesting one. Jev is bad at numbers, so it never sees one. Code pulls the data, 360,000 samples for that hundred-hour question, and turns every series into semantic facts first. A signal is rising, falling, or flat. It is past its alarm, near it, or clear of it. It is moving fast or drifting. The data is complete or it has gaps. Events become counts by kind. Service history becomes “past the interval” or “most of the interval used.”
Then I ask closed questions about the situation and the layout, all in one call. How much attention this needs on a three-level scale. Which of seven headline sentences fits. Which measurement to lead with. Whether the story, the numbers, or the picture should come first. What form the chart takes. Whether this person needs a maintenance action plan even though they did not ask for one. For each widget the fetched data can support, whether it belongs.
That call runs in 140 to 370 milliseconds. Jev only ever sees the words, and it picks. Code orders the chosen widgets by the emphasis the model assigned, sizes them, binds every value to a path in the data model, and assembles the prose from templates keyed to the answers. Every measured value is interpolated at render time. Nothing in the path writes a number. Nothing is generated anywhere.

Here is the deployed benchmark. Four scenarios per configuration, single runs, synthetic historian, so treat every row as directional.
| Composer | Completed | Median to dashboard | Output tokens |
|---|---|---|---|
| Decision model only | 4/4 | 3.8 s | 0 |
| Router plus fast small model | 4/4 | 6.1 s | 4,427 |
| Fast small model, pinned | 4/4 | 6.3 s | 3,711 |
| Frontier model, pinned | 4/4 | 29.0 s | 9,649 |
Same completion rate. Roughly a seventh of the latency. No output tokens. Most of the 3.8 seconds is the historian query, not the model. Every one of my storyline prompts composes in under 1.3 seconds and most of them in under half a second.
You are thinking it is just a classifier. It is.
The same way a spreadsheet is a calculator. The novelty is not the mechanism. It is that there is no training set. The classes are sentences you wrote, and you can change them at runtime. We added one intent as a single line of English, and an hour later a misspelled question routed to it at 98%. What changed my own mind was building the second version of the router and realizing I had not trained anything. I wrote questions.
The fair pushback is that a purpose-built encoder trained on your specific data will beat this model on accuracy every day, and run locally in milliseconds. For regulated or air-gapped sites that is a real argument for custom training. Where the comparison misses is that intent is the small part of what gets asked. The catalog might rarely change, but the questions can be new and exotic. Which machine is named is one question per machine, generated from the roster at request time, so a new machine is a new question with no labels and no retrain. And the second half is not classification at all. It is scoring a situation on rubrics somebody wrote in English. How much attention does this need? Which headline fits? Lead with the numbers or the picture? To train that you would need labeled examples for every situation, and the day someone changes a rubric you would need them all again.
So the honest boundary is this. Stable classes and lots of traffic, train the small model. Rules that are sentences, questions generated from your own data, and judgments on rubrics that change, that is where a decision model wins. Nothing stops you running both, the small model in front for intent and the decision model behind it for the judgment.
The other thing “just a classifier” misses is that I did not prompt my way past the model’s weaknesses. I built the router around the vendor’s own list of what it does badly. No arithmetic. No counting. No comparing dates. One literal question per fact. An “other” option on every question. A confidence floor under which nothing is fetched and the frontier model starts cold. That list is most of the engineering, and it is a transferable habit. Read what a model says it cannot do and design to that, rather than to the demo.
What it cost me
Prose is assembled, not written. The widgets come from a palette I wrote in advance. A shape nobody anticipated cannot appear on screen.
Is it a cage? Yes, in the sense of holding being somewhat limited. Assembled prose will not surprise an operator. That is a real cost. In a control room it fits, because you want to be speaking the exact language a plant engineer speaks, and that has to be known ahead of time. But the door is not closed. Questions Jev cannot classify, a trend, a comparison, a trip, go to an LLM against the same catalog and the same validator. It is a cage with a door.
Two composition paths means two test surfaces. Since this is a demo and not in production, I do not have punt rates yet. What the user sees when it happens is mostly time, about a second on the Jev path versus several on the LLM. And it is written down. Under every dashboard there is a “how this was composed” block that says which composer built it and, if it was handed off, why. Users can see the fallback. That is deliberate, and it is also a thing you have to test.

The confidence scores are not a free lunch either. An independent benchmark found the model averaging 88% reported confidence against roughly 80% actual accuracy on a 77-category intent task, so it runs overconfident out of the box. Temperature scaling cut that gap by about two-thirds in their testing. My gates are constants I set by hand. Below 50% nothing is fetched and the question goes to the LLM, and there are a few more like it for pulling the event log and service history. None of them have been tuned against data yet. If you are going to let a confidence score decide whether the fast path runs or the frontier model takes over, calibrate it on your task first.
The compliance question comes down to what leaves the building. On the Jev path the line is narrower, because Jev never sees the raw data. On the LLM path real numbers are required. What is allowed to leave the site? The vendor is weeks old with no published security certification yet, which is a plain reason to keep client data off it while due diligence runs, and it is where my own build sits today: synthetic plant data, benchmarked against published engineering ranges, but synthetic.
The constraint is the governance model
Conceptually this is still generative UI as we already do it with a frontier model. You define a set of components ahead of time and the model chooses which fit the ask and the data. The shift is in how they are assembled on the server.
Enterprises accepted GenUI in the first place because of a constraint. A2UI and the Flutter GenUI SDK do not let the model emit code. The model selects from a pre-validated catalog and a renderer turns the selection into native widgets. That is what made the security story survivable in a regulated environment.
A decision model is the same idea followed to the end. A catalog already turned the model’s job into choosing. A decision model takes that seriously and gives you a thing that can only choose, with nothing left to sanitize. Three things it will not do that matter. No code created. No numbers made up. No text hallucinated.
Other teams landed on the same prepare-then-place shape the same week, and that is Jev forcing the shape rather than three teams being clever. The difference I care about is that they put the model in front of a set of components, and we put it in front of a contract: a governed catalog where nothing the model can name is unbuilt, nothing renders unvalidated, and every number is a pointer the server checked. It does not matter what is drawing the screen. The guarantees already happened before the message left.
The validator is where that contract gets enforced, and honestly it has caught my mistakes more than it has caught the model. But it has caught the model, and a couple of those would have been on screen looking real. Early on, one model composed before it had run any query and wired a tile to a “total machines” field that never existed, because it never asked for a fleet summary. Rendered as text, that is a zero or a blank on a tile that reads like a real count. I removed the option entirely. You cannot compose until a query has run. Another time a model pointed a table at a summary, which is one set of numbers, when a table needs rows. The data was real, so an existence check let it through, and the renderer drew an empty card with a title on it. On a plant screen that is dangerous, because an empty table under “alarms in the last 12 hours” reads as no alarms. The validator now asks whether the data is the right kind of thing for the block, and not only whether it exists.
My validator rejects any literal decimal in prose, and a separate module defines the short allowlist of numbers a model may legitimately write: configured setpoints, constants quoted in tag descriptions, and nameplate ratings. Everything else must come from real data. The one time a model typed a measured value straight into a sentence as a decimal, the validator rejected it and the second pass used a placeholder that points at the data instead.
The palette that reads as a limitation is a hard ceiling on what can ever render. A regulated buyer has been asking for that ceiling since the first GenUI demo.
Count your composer’s decisions before you swap the model
If your GenUI prototype is stuck in the latency review, list every decision your composer currently makes before it writes anything. Dashboard type, entities named, time window, which data to fetch, which model tier to use. If more than half of that list is a closed question with a fixed set of answers, you are paying generation prices for classification.
Then measure completion rate before you measure latency. A model can be fast, always emit valid structure, gather every piece of data, and then simply never build the dashboard. Mine did. The commit that forced completion measured a model that called three data tools across 262 kB of stream and never built anything, and a hallucination metric scored it as a clean run. Completion rate went in as the first column of the bench that same day.
Fifty-four seconds became four, and on most questions closer to one. When a UI decision costs 300 milliseconds and a fraction of a penny, you stop designing screens once at ship time and start composing them continuously, per component, per person, per minute. Because every one of those calls is a choice among things you already designed and built, the density of composed flows goes up without the risk. The next step is that when an operator needs a line of dynamic text, that becomes one very small, pointed LLM call, and no longer the whole dashboard.
Stop paying an LLM to retype data into JSON. Let a decision model choose the view and let your code fill it in.
