You know the twenty-five clicks.
Open the explore. Pick the dimension, the measure, the pivot. Change the visualization. Find the axis tab, turn off the axis title. Set the series color. Set the number format -- not that one, the one on the field, not the one on the series. Save to a dashboard. Resize the tile. Set the tile title, which is a different field from the look title. Add a filter, wire it to the tile, then to the other four. Save.
Twenty-five clicks. I was the fastest on my team at them, because I'd done them four hundred times.
Then the sales director asked for the same thing, broken out by region.
The canvas was a programming language
It's tempting to call the drag-and-drop builder a bad interface. It wasn't. It was a language.
Every click set a property. The properties serialized to a config blob. The blob got rendered. That's a compiler, and the canvas was its only editor -- a programming language with no text representation, no diff, no code review, no local development, no tests, and exactly one IDE, which ran in a browser tab and belonged to your vendor.
And it was the right call at the time. Writing that page by hand meant a week of front-end work per dashboard: fetching, filtering, cross-linking, drilling, formatting, making it responsive, making it not melt when the warehouse got slow. No data team was going to hand-author forty of those. So the vendors built a canvas that let a non-engineer produce a competent one in twenty-five clicks, priced it per seat, and it worked for fifteen years.
I want to give Looker its due, because it got the most important part right: a governed model belongs between your data and the people asking questions. Business logic defined once. Every consumer getting the same answer. That insight is more correct in 2026 than it was in 2012, because the consumers are now agents, and an agent needs the meaning written down even more than an analyst does.
What aged out was the packaging. The model was locked in a monolith, and the only thing the monolith would emit was its own dashboards, built its own way, in its own format, which you couldn't read.
The dashboard is NOT dead
There's a fashionable take that AI kills the dashboard, because now you can just ask.
That take misunderstands what a dashboard is. A dashboard isn't a way of asking a question. It's a way of never asking it again. It's what a team agreed the twelve numbers are, arranged the way they agreed to look at them, so that on Monday morning nobody re-derives anything and nobody argues about definitions. Conversation is wonderful for the question you haven't asked yet, and terrible for the one you ask every week.
Even with a governed model, chat is the wrong tool for that weekly question -- and not because the answers drift. What "month" means, gross or net, trailing or calendar: those belong in the model, defined once, so every chat comes back with the same number. The reason is simpler: an agent re-deriving the same twelve numbers every Monday spends minutes and tokens arriving exactly where it arrived last Monday, multiplied by everyone who asks. A dashboard is that work done once and kept. The model makes the answer right; the dashboard makes it instant and free.
So keep the dashboard. Kill the canvas. The interesting question is what replaces it, and the answer isn't another authoring UI.
It's an agent with skills. Not a chatbot bolted onto the old builder -- a modern coding agent, Claude and its peers, handed two things before it writes a line: a governed model that says what the data means, and skills -- the craft of building a data app written down as instructions the agent actually follows. Which names are real, what missing data means, how a tile earns trust, how the result gets tested. The person who wants the dashboard says the sentence; the agent does the twenty-five clicks' worth of work as code you can read. The rest of this post unpacks that answer in order: first the model, then what the agent writes, then the skills themselves.
The loop in action: a sentence in, a reviewable data app out.
Start with the model, or none of this works
The reason an agent can be trusted to write a dashboard isn't that the agent is smart. It's that it has very little room to be wrong.
Point an agent at raw tables and it will write confident code over status_final and status_v2 and pick the wrong one. Point it at a governed data model -- a Malloy package where the joins are declared, the measures are defined, the access rules are annotated, and every field carries documentation written for whoever reads it next -- and the ways it can fail shrink dramatically. The agent can't invent a number. It can only ask for one that somebody already defined and reviewed.
Kyle made the general version of this argument in Charting is Commoditized. Meaning Is the Product.: generating a chart got cheap, deciding what the chart means did not, and the model is where the meaning lives. If you want to see what goes into one, start with the model concepts doc.
Everything below assumes the model is there -- and the analysis too. We've covered both in earlier posts: how an agent builds a semantic model and how an agent turns a question into a trustworthy answer. The second one matters more here than it looks: a dashboard exists to tell a story, and analysis is where that story gets drafted and refined before anything is pinned to a page. Do that work first and it carries forward naturally -- the analysis becomes the context the agent builds the dashboard data app from. Without the model, an agent writing HTML straight against your warehouse will happily chart the wrong numbers. With it, the HTML is just presentation, and presentation was never the hard part.
What the agent actually writes
Here's the whole mechanism. It's smaller than you expect.
A Malloy package becomes a web app by having a public/ directory. That's the entire opt-in -- no flag in publisher.json, no separate deployment, no registration step.
The page loads one script and gets a query function:
<script src="/sdk/publisher.js"></script>
<script type="module" src="./app.js"></script>const rows = await Publisher.query(
"subscriptions.malloy",
"run: subscriptions -> monthly_revenue",
)rows is an array of plain objects. Render them however you like. There's no build step, no npm install, no framework, no bundler. Plain ES modules just resolve.
Look at what stays private in that tree. Only public/ ever reaches the browser. The models, the data files, and the manifest are only reachable through the query API, and that API is the same governed path every other query takes -- so filters, access modifiers, and #(authorize) rules all apply, unchanged, to whoever happens to be looking at the page. Two people with different permissions open the same app and see different data. Nobody reimplemented row-level security in JavaScript, because JavaScript never got the chance to see the rows it wasn't allowed to see.
The craft didn't disappear -- it got written down
This is where the twenty-five clicks actually went. A dashboard used to cost one of two things: a builder and someone fluent in its clicks, or a world-class front-end engineer hand-building a custom data app. The skills are how you get the second result without paying either price: the craft is written down as instructions the agent follows on every build, and they ship open source in Malloy Publisher alongside the rest of the agent tooling. malloy-html-data-apps is the build recipe, malloy-html-data-app-runtime covers the query and render layer, and malloy-html-data-app-embedding covers putting the result in someone else's page.
They read less like documentation and more like a code review you've already had. A few of the rules, verbatim:
- On field names: "A name you derived or assumed is a bug waiting to surface as an empty tile." Read the model. Don't compute names.
- On missing data: "Missing is not zero." Don't
|| 0a gap in a time series, because a real-looking zero reads as we hit nothing that month. - On rendering: never
innerHTMLwith an interpolated value, because a query result renders whatever markup it contains. "This is a hard build rule, not a lint suggestion." - On assumptions: if you couldn't model something, say so in the app. "The non-technical user cannot see your reasoning; bury a caveat and you have misled them."
And the line that governs all of it: "You are building for someone who cannot tell a correct dashboard from a broken one. Verification is your job, not theirs."
That's what makes this an engineering practice instead of a demo. The skills even include a test harness: fake Publisher.query, load the real page in a real browser with canned data, and assert on what actually rendered -- because the bugs live in the render path, not in the query.
Shipped like production software
Once the dashboard is source code, everything the industry already knows about shipping software applies, and none of it had to be invented for BI.
The app is text, so it diffs. A change to a tile is a pull request with a reviewer, not a Slack message that says hey did someone change the Q3 dashboard. It runs against a local Publisher before it goes anywhere. Publishing is publishing a package version, so the app and the data model it queries move as one immutable version, promoted only once it's fully built, with rollback that sticks.
That last property kills a specific recurring humiliation. In the old world, someone renames a field in the model and forty dashboards break at different times over the following week, discovered one at a time by the people who depended on them. Here, the app ships inside the same version as the model it was written against. They can't drift apart, because no configuration exists where one moved and the other didn't.
You can already drive that publish from CI, so the whole loop is the loop your engineers already run: branch, review, merge, ship, roll back if it's wrong.
The spec was in her head all along
Now the part that actually changes a data team's week.
Look at a BI engineer's queue honestly. It isn't full of hard problems. It's full of add this column, break this out by segment, same report but for the EMEA lists, can this be a line chart. Every one of those is trivial. Every one of them takes a day, because it requires the twenty-five clicks, and the twenty-five clicks require you.
Now look at who's asking. The sales director who wants revenue broken out by region isn't making a vague wish. She knows exactly what she wants to see -- which regions roll up where, what order the tiles should read in, what a suspicious number would look like -- because she's the one who runs the Monday call where it gets read. The complete specification for that dashboard already exists. It is in her head, in English, and it always was. The old workflow existed to force it through two lossy conversions: she compresses what she knows into a ticket, and a week later someone with the clicking skill decompresses the ticket into a blob and hands back an approximation, for another round of close, but can the trend go first.
That was never a capacity problem. It's a translation problem, and it taxes both ends: her knowledge flattened into a ticket, your expertise spent converting the ticket into a config blob -- the least valuable thing you know how to do.
Move the translation to the agent and the person who knows the solution is the person who builds it. She says what she wants, in the sentence she was always going to write anyway, to an agent that reads the governed model before it writes a line. What lands on your desk is not her ticket. It's her draft, as a diff. Remember the request that opened this post -- same thing, broken out by region? Here it is, in its entirety, in the new world:
monthly_revenue: {
title: "Monthly revenue",
- query: "run: subscriptions -> monthly_revenue",
- kind: "line",
+ query: "run: subscriptions -> monthly_revenue + { group_by: region }",
+ kind: "multi_line",
+ seriesBy: "region",
map: { x: "month", y: "revenue" },
},Four changed lines. region didn't need defining, because it already lives in the model, where it was reviewed once by the person who knows which of the two region columns is authoritative. You read this the way you read any diff: right source, right measure, refinement instead of a rewrite, nothing dropped. Then you approve it, and merging publishes it.
Read that diff one more time, because it's the entire argument of this post in nine lines. In the old world this request was twenty-five clicks that produced nothing you could review at all -- the change existed only as a difference between two saved states of a blob. Here it's a text change to a catalog entry, and everything else in this post is just the consequences of that.
The BI team stops being a ticket queue and becomes what it should have been: the owner of the model and the reviewer of the change. Your leverage goes up, not down -- the thing you're scarce for is judgment about whether a number is right, and you get all of it back. And she gets what she was asking for all along: not a slot on your backlog, but the dashboard that was in her head, live the same afternoon, built on definitions you vouched for.
This is also the honest answer to why self-serve BI failed the first time, because self-serve got the diagnosis exactly right: the people asking always knew what they wanted. Then it prescribed the canvas. It asked the sales director to learn a builder's trade to express something she could already say in a sentence, and it put no expert anywhere in the loop. The few who persisted produced the three hundred ungoverned dashboards nobody trusted; everyone else went back to the ticket queue. The fix was never to teach her the clicks. It was to let her say the sentence, ground it in a model an expert owns, and move the expert's judgment to the end of the loop, where it's cheap to apply -- instead of the expert's hands at the beginning, where they were the bottleneck.
Step back and count the hands a dashboard used to pass through. A business analyst wrote down what the business needed. A data modeler turned that into a model. A data analyst worked out the numbers. A front-end engineer -- if you were lucky enough to have one -- built anything custom. Four roles, three handoffs, each one lossy. That's what this series has been dismantling one post at a time: the modeling post, the analysis post, and now this one add up to a single person who knows the business running the entire path -- model, analysis, live data app -- with the agent carrying the craft at every step and an expert reviewing the result at the end.
Three things a tile in a builder cannot do
This isn't just the old thing with fewer clicks. You get a few capabilities a builder never offered.
An agent-authored tile can look like anything a browser can draw. A builder limits you to the chart types, visualizations, and formatting the vendor thought were important -- not you. Here there is no such list. Use any charting library that runs in a browser. Theme it with your fonts, your logo, your palette. Add animations, ticker tapes, whatever the room calls for. A true blank canvas, without the toil it used to require.
An agent-authored tile can show its own work. Put a control on every card that opens the exact Malloy that produced it, and another that shows the rows that came back. A user who doesn't trust a number stops filing a ticket and starts reading the query. In a builder, that query doesn't exist in readable form anywhere; it's a blob and a generated SQL plan.
And a tile can hand itself to an agent. Data apps running in Credible get a one-way channel to the Credible Agent: a tile can open the chat panel with its own query and its actual rows already in hand. The follow-up question -- why did this dip in March -- gets asked against the same governed model, from inside the dashboard, without the user re-explaining what they're looking at. The grounding matters more than the convenience: an agent handed the exact query and the actual rows spends its turns answering instead of rediscovering what the page already knew.
Try it
The shortest way in is to read a finished one before you build anything. Clone github.com/malloydata/publisher, start the server, and open the bundled html-data-app package: a SaaS-subscriptions dashboard of plain HTML files, served as-is against a DuckDB-backed model, no build step and no credentials to configure. Its public/ directory is the whole shape this post argues for, in about as few files as it can be shown.
Then build one. Inside Credible, describe the charts, filters, and layout you want and the in-app agent writes the app into your draft package. With the developer tools, your own coding agent builds to the same recipe against a local Publisher, and edits to public/ reload the open page while model edits recompile the package. Either way the skills from this post are the ones running, so you get real field names read from the model, missing data rendered as missing rather than as zero, and every tile owning its loading and error states.
No model yet? Start there instead, because the ordering in this post is not a suggestion. The tutorial gets an agent answering real questions against your own CSV, Parquet, JSON or Excel file in a few minutes.
- Read the mechanics: the data apps doc has the file layout, the query API, and exactly what reaches the browser
- Read the skills your agent runs:
malloy-html-data-appsin Publisher - Tell us where it falls short: Malloy community Slack
Keep the dashboard. Kill the canvas.
The canvas was never the point. It was scaffolding around a problem that has since become cheap to solve. What's left is the part that was always valuable: a governed model that says what your data means, and a dashboard you can read.