DEV Community

Cover image for Stop shipping dashboards. Ship the ability to make one.
Jan Zimprich
Jan Zimprich

Posted on

Stop shipping dashboards. Ship the ability to make one.

Every internal app I have ever worked on has the same bug, and it is not in the code.

It ships one dashboard for twelve departments. Controlling wants margin per region. Support wants the last five tickets of exactly one customer. The warehouse wants one number, large enough to read from across the hall. So the screen becomes a compromise, and everybody quietly learns to ignore eighty percent of it. Then we bolt on filters. Then saved views. Then a report builder with forty fields that three people ever open.

The view is wrong because it was decided months before the question was asked.

So I stopped shipping the view. In DRYL the app now ships the ability to make one — in the moment, in the words the person already uses. This post is about the three pieces that took it from a party trick to something I would put in front of a finance team: the canvas, the document, and the dock — plus the voice session that turned out to be the most fun part to build.

The canvas is a document, not generated markup

DrylAiCanvas renders a CanvasSpec: a tree of typed nodes. The model does not emit HTML, and it certainly does not emit Razor. It emits this:

{
  "id": "revenue",
  "type": "lineChart",
  "data": { "source": "sales.byMonth", "params": { "year": 2026 }, "refresh": "interval:30s" }
}
Enter fullscreen mode Exit fullscreen mode

The type comes from a closed catalog — stack, grid, card, tabs, accordion, form, stat, kpi, table, dataGrid, timeline, the chart family, markdown, list, keyValue, code, emptyState, plus the interactive ones: inputText, textarea, select, slider, toggle, button. Each renders through the same components the rest of the app is built from, so a generated view is not a second design system with worse spacing.

An unknown type or an invalid prop does not break the screen. It renders as a placeholder, and the validation failure comes back to the model as a plain corrective sentence it repairs on the next turn:

Artifact created: 14 elements, 3 inputs. Some elements were invalid and are shown
as placeholders — fix via update_artifact: unknown data source 'sales.byQuarter'.
Enter fullscreen mode Exit fullscreen mode

That loop is most of why this works. The model is authoring a document in a vocabulary I control. It is not writing my frontend.

The model never touches your numbers

This is the constraint everything else hangs off: a model that writes numbers into a chart is a demo, not software.

Data sources are registered at startup. The model sees a name, one sentence of description, a parameter schema derived from a record, and the result shape. It never sees a row.

public sealed record SalesParams(int Year, string? Region = null);

builder.Services.AddDrylCanvasDataSource("sales.byMonth",
    "Revenue per month in thousands of euros.",
    async (SalesParams p, CanvasDataContext ctx, CancellationToken ct) =>
    {
        var db = ctx.Services.GetRequiredService<AppDb>();
        var rows = await db.SalesAsync(p.Year, p.Region, ct);
        return CanvasData.Series(rows.Select(r => r.Month), ("Revenue", rows.Select(r => r.Total)));
    });
Enter fullscreen mode Exit fullscreen mode

ctx.Services is the circuit's scope. Tenant and signed-in user are resolved there and never travel through the spec, which means the model cannot influence them by writing a clever prop. The worst a hallucinated binding can do is name a source that does not exist — and that is a corrective sentence, not an incident.

Actions work the same way, with one extra rule I am fond of:

builder.Services.AddDrylCanvasAction("order.approve",
    "Approves an order. Asks the user to confirm.",
    async (ApproveArgs a, CanvasActionContext ctx, CancellationToken ct) => { /* … */ });
Enter fullscreen mode Exit fullscreen mode

The AI may place a button and label it. It may never press one. Only a user press runs a command. There is no code path where the model triggers an action, and that is a structural property, not a prompt instruction.

CanvasDocument — the part that makes it software

A view that dies on F5 is a demo. CanvasDocument is the snapshot that survives a reload, a user switch and a deployment.

var doc = CanvasDocument.Capture(workspace, "Monday morning", form);
await store.SaveAsync(doc);
// …later, on another machine, after a deploy
doc.Restore(workspace);
Enter fullscreen mode Exit fullscreen mode

Four decisions in there that I would defend in a code review:

Bindings travel; numbers do not. A restored document asks the registered sources for fresh values. A document is never a stale copy of a database — it is a saved question, not a saved answer. Open Monday's board on Thursday and it is Thursday's data.

Reading is gated on a schema. TryFromJson is the only entry point, because that is where the version check lives. A document written by a newer build is refused with a sentence a human can read, instead of half-deserializing into something subtly wrong.

AsTemplate(title) gives you the same views with no store id and a new title. That is how an app ships its standard dashboards: the team's "Monday morning" board becomes a starting point people fork and bend, instead of a screenshot in Confluence.

Live form values fold in. At capture, what the user has typed is folded into the nodes' value props; interactive nodes seed themselves from that prop on restore. The half-filled form comes back half-filled, and the loading path contains not one line about it.

And a small one: views that are mid-exit animation are skipped. What is animating away does not belong in a document.

The dock is the medium

The thing you change the document through is DrylCanvasDock, and the design brief was one sentence: a command bar, not a chat.

The artifact is the answer. The text beside it is not, so it does not get to own half the screen. One input, one live status line — Building · 7 elements — and the full transcript only if you ask for it.

Two details worth stealing:

The dock lives in the browser's top layer (popover="manual"), because a position: fixed element is measured against the nearest ancestor with a transform or backdrop-filter — which, in an app made of glass cards, is essentially always some card. If your floating panel has ever mysteriously clipped, that is why.

Selecting an element on the canvas puts a context chip in the dock and prefixes your next sentence with the node's id, type and label. So "make it a bar chart" lands on the right node instead of on the model's best guess. The update then streams in as a patch: ops applied one per 260 ms beat, so a change reads as choreography rather than a flicker. One operation, one movement.

And then you just talk to it

The newest piece: DrylVoiceRun. You press the microphone in the dock and the dock becomes the conversation — composer, chips and suggestions step aside for an orb and the last line that was said.

The audio never touches .NET. The browser holds a WebRTC peer connection straight to the realtime API. Routing audio through a Blazor Server circuit costs a few hundred milliseconds in each direction, which is precisely the difference between a conversation and a walkie-talkie.

The server's job is one HTTP call: mint an ek_… client secret that expires in 60 seconds with the entire session baked into it. The browser never sees the API key, and it cannot change the model, the instructions or the tool list — those are inside the minted token.

Tool calls come back over the data channel and land in C#:

[JSInvokable]
public async Task<string> OnToolCallAsync(string callId, string name, string argumentsJson)
{
    var tool = Options.FindTool(name);          // only what the host registered
    if (tool is null) return Fail($"Unknown tool \"{name}\".");
    var result = await tool.InvokeAsync(ParseArguments(argumentsJson));
    return result as string ?? JsonSerializer.Serialize(result);
}
Enter fullscreen mode Exit fullscreen mode

The browser only ever supplies a name. What runs is whatever the host put in the list, so a manipulated page cannot invent a tool. And it never throws: a model waiting for a result that never arrives stops mid-conversation with no way back, while an error it can read keeps it talking.

Hand it the same tool list the text agent has — including create_artifact, update_artifact and open_view — and the loop closes. You say "show me last quarter by region, and put yesterday's open orders next to it", and while you are still finishing the sentence the chart is drawing itself and a second view is opening beside the first.

Two more things I would do again:

The voice maps onto the library's existing five AiState values — Listening / Thinking / Speaking become Active / Thinking / Streaming. No new vocabulary. The whole UI breathes in one language, so you can feel where the AI is working without reading a label.

The input level is deliberately not in .NET. A level that updates 30 times a second and raises a state-change event is 30 renders a second for a decoration. It stays in the browser and drives a CSS variable on the orb.

What this actually changes

Not "AI in your app" as a chat bubble in the corner that summarizes the page you are already looking at. The canvas is the surface, and the assistant is how you reshape it. The screen stops being a compromise between twelve departments and starts being the answer to the question one person has right now — and then, if it turns out to be a good screen, they save it and it becomes part of the app.

Honest limits, because I would want to know them:

  • The catalog is closed by design. It will not render your arbitrary component. That is the price of the model never being able to break the UI.
  • A generation costs a second or two. Choreography helps; it does not make it instant.
  • Data sources and actions need curating. A vague description is a bad UI, reliably. This is the new "naming things".

DRYL is open source, Blazor Server and WebAssembly, zero npm packages. The canvas lives in DRYL.Components and the agent side in DRYL.Components.Agents.

👉 components.dryl.dev

If you build something with it — or break it in an interesting way — I would genuinely like to hear about it.

Top comments (0)