<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jan Zimprich</title>
    <description>The latest articles on DEV Community by Jan Zimprich (@dryl_dev).</description>
    <link>https://dev.to/dryl_dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4021819%2F88b260ff-fc98-48ac-a0fc-7cbaa073e227.png</url>
      <title>DEV Community: Jan Zimprich</title>
      <link>https://dev.to/dryl_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dryl_dev"/>
    <language>en</language>
    <item>
      <title>Stop shipping dashboards. Ship the ability to make one.</title>
      <dc:creator>Jan Zimprich</dc:creator>
      <pubDate>Thu, 30 Jul 2026 14:39:18 +0000</pubDate>
      <link>https://dev.to/dryl_dev/stop-shipping-dashboards-ship-the-ability-to-make-one-11lc</link>
      <guid>https://dev.to/dryl_dev/stop-shipping-dashboards-ship-the-ability-to-make-one-11lc</guid>
      <description>&lt;p&gt;Every internal app I have ever worked on has the same bug, and it is not in the code.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The view is wrong because it was decided months before the question was asked.&lt;/p&gt;

&lt;p&gt;So I stopped shipping the view. In &lt;a href="https://components.dryl.dev" rel="noopener noreferrer"&gt;DRYL&lt;/a&gt; the app now ships the &lt;em&gt;ability to make one&lt;/em&gt; — 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The canvas is a document, not generated markup
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;DrylAiCanvas&lt;/code&gt; renders a &lt;code&gt;CanvasSpec&lt;/code&gt;: a tree of typed nodes. The model does not emit HTML, and it certainly does not emit Razor. It emits this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"revenue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lineChart"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sales.byMonth"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"params"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"year"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"refresh"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"interval:30s"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;type&lt;/code&gt; comes from a closed catalog — &lt;code&gt;stack&lt;/code&gt;, &lt;code&gt;grid&lt;/code&gt;, &lt;code&gt;card&lt;/code&gt;, &lt;code&gt;tabs&lt;/code&gt;, &lt;code&gt;accordion&lt;/code&gt;, &lt;code&gt;form&lt;/code&gt;, &lt;code&gt;stat&lt;/code&gt;, &lt;code&gt;kpi&lt;/code&gt;, &lt;code&gt;table&lt;/code&gt;, &lt;code&gt;dataGrid&lt;/code&gt;, &lt;code&gt;timeline&lt;/code&gt;, the chart family, &lt;code&gt;markdown&lt;/code&gt;, &lt;code&gt;list&lt;/code&gt;, &lt;code&gt;keyValue&lt;/code&gt;, &lt;code&gt;code&lt;/code&gt;, &lt;code&gt;emptyState&lt;/code&gt;, plus the interactive ones: &lt;code&gt;inputText&lt;/code&gt;, &lt;code&gt;textarea&lt;/code&gt;, &lt;code&gt;select&lt;/code&gt;, &lt;code&gt;slider&lt;/code&gt;, &lt;code&gt;toggle&lt;/code&gt;, &lt;code&gt;button&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Artifact created: 14 elements, 3 inputs. Some elements were invalid and are shown
as placeholders — fix via update_artifact: unknown data source 'sales.byQuarter'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model never touches your numbers
&lt;/h2&gt;

&lt;p&gt;This is the constraint everything else hangs off: &lt;strong&gt;a model that writes numbers into a chart is a demo, not software.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;SalesParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDrylCanvasDataSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sales.byMonth"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"Revenue per month in thousands of euros."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SalesParams&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CanvasDataContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppDb&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SalesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;CanvasData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Series&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Month&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Revenue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Total&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ctx.Services&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Actions work the same way, with one extra rule I am fond of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDrylCanvasAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"order.approve"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"Approves an order. Asks the user to confirm."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ApproveArgs&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CanvasActionContext&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;CanvasDocument&lt;/code&gt; — the part that makes it software
&lt;/h2&gt;

&lt;p&gt;A view that dies on F5 is a demo. &lt;code&gt;CanvasDocument&lt;/code&gt; is the snapshot that survives a reload, a user switch and a deployment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CanvasDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Capture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workspace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Monday morning"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// …later, on another machine, after a deploy&lt;/span&gt;
&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Restore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workspace&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four decisions in there that I would defend in a code review:&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Reading is gated on a schema.&lt;/strong&gt; &lt;code&gt;TryFromJson&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;AsTemplate(title)&lt;/code&gt;&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live form values fold in.&lt;/strong&gt; At capture, what the user has typed is folded into the nodes' &lt;code&gt;value&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;And a small one: views that are mid-exit animation are skipped. What is animating away does not belong in a document.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dock is the medium
&lt;/h2&gt;

&lt;p&gt;The thing you change the document through is &lt;code&gt;DrylCanvasDock&lt;/code&gt;, and the design brief was one sentence: &lt;strong&gt;a command bar, not a chat.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 — &lt;code&gt;Building · 7 elements&lt;/code&gt; — and the full transcript only if you ask for it.&lt;/p&gt;

&lt;p&gt;Two details worth stealing:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then you just talk to it
&lt;/h2&gt;

&lt;p&gt;The newest piece: &lt;code&gt;DrylVoiceRun&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The audio never touches .NET.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;The server's job is one HTTP call: mint an &lt;code&gt;ek_…&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Tool calls come back over the data channel and land in C#:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;JSInvokable&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;OnToolCallAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;callId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;argumentsJson&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FindTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// only what the host registered&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Unknown tool \"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;\"."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;InvokeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ParseArguments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;argumentsJson&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser only ever supplies a &lt;em&gt;name&lt;/em&gt;. 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.&lt;/p&gt;

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

&lt;p&gt;Two more things I would do again:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually changes
&lt;/h2&gt;

&lt;p&gt;Not "AI in your app" as a chat bubble in the corner that summarizes the page you are already looking at. The canvas &lt;em&gt;is&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;Honest limits, because I would want to know them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;A generation costs a second or two. Choreography helps; it does not make it instant.&lt;/li&gt;
&lt;li&gt;Data sources and actions need curating. A vague description is a bad UI, reliably. This is the new "naming things".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;DRYL is open source, Blazor Server and WebAssembly, zero npm packages. The canvas lives in &lt;code&gt;DRYL.Components&lt;/code&gt; and the agent side in &lt;code&gt;DRYL.Components.Agents&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://components.dryl.dev" rel="noopener noreferrer"&gt;components.dryl.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you build something with it — or break it in an interesting way — I would genuinely like to hear about it.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>blazor</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Streaming AI straight into Blazor UI: typed structured generation and live AI-built dashboards</title>
      <dc:creator>Jan Zimprich</dc:creator>
      <pubDate>Wed, 22 Jul 2026 20:23:22 +0000</pubDate>
      <link>https://dev.to/dryl_dev/streaming-ai-straight-into-blazor-ui-typed-structured-generation-and-live-ai-built-dashboards-3cmi</link>
      <guid>https://dev.to/dryl_dev/streaming-ai-straight-into-blazor-ui-typed-structured-generation-and-live-ai-built-dashboards-3cmi</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F25sfsiagjzben3krp3pl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F25sfsiagjzben3krp3pl.png" alt="DRYL Overview" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most AI UIs today are the same thing: a chat bubble filling up with Markdown. That's fine for text — but your app isn't made of text. It's made of cards, charts, badges, timelines, forms.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;&lt;a href="https://components.dryl.dev" rel="noopener noreferrer"&gt;DRYL&lt;/a&gt;&lt;/strong&gt; — an open-source, AI-native component library for Blazor (Server &amp;amp; WebAssembly) — around one idea:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The AI shouldn't stream strings at your users. It should stream &lt;em&gt;UI&lt;/em&gt;.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In this post I want to show you the two features I'm most excited about:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DrylAiGenerate&amp;lt;T&amp;gt;&lt;/code&gt;&lt;/strong&gt; — typed structured generation that streams a &lt;em&gt;partial object&lt;/em&gt; into real Blazor components, token by token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DrylAiCanvas&lt;/code&gt;&lt;/strong&gt; — the model builds a live, &lt;em&gt;interactive&lt;/em&gt; dashboard as a chat artifact, and clicks inside it route back to the model.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;(All GIFs below are recorded from the live docs at &lt;a href="https://components.dryl.dev" rel="noopener noreferrer"&gt;components.dryl.dev&lt;/a&gt; — no mockups.)&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem with streaming text
&lt;/h2&gt;

&lt;p&gt;Token streaming is a solved problem for chat. But the moment you want the model to produce something &lt;em&gt;structured&lt;/em&gt; — a recipe, a product draft, a report — you usually end up with one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wait for the full JSON&lt;/strong&gt;, deserialize, then render. The user stares at a spinner for 20 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stream Markdown&lt;/strong&gt; and lose all structure — no charts, no badges, no forms, no interactivity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you actually want: the model emits JSON for your C# type, and your UI renders the object &lt;em&gt;while it's still incomplete&lt;/em&gt; — title appears first, then the description grows character by character, then list items pop in one by one.&lt;/p&gt;

&lt;p&gt;That's exactly what &lt;code&gt;DrylAiGenerate&amp;lt;T&amp;gt;&lt;/code&gt; does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Typed structured generation: &lt;code&gt;DrylAiGenerate&amp;lt;T&amp;gt;&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;You define a plain C# record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;Recipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Serves&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Ingredient&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Ingredients&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Step&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Steps&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ask the runner for a stream (it sets a JSON-schema response format for &lt;code&gt;T&lt;/code&gt; and yields raw tokens):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Works with any Microsoft.Agents.AI provider — OpenAI, Azure, Ollama, ...&lt;/span&gt;
&lt;span class="n"&gt;_stream&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GenerateStreamingAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Recipe&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"recipe"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And bind it to your UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;DrylAiGenerate T="Recipe" Source="_stream" Key="recipe"&amp;gt;
  &amp;lt;ChildContent Context="snap"&amp;gt;
    &amp;lt;DrylCard Ai="@snap.State"&amp;gt;
      &amp;lt;h3&amp;gt;@snap.Value?.Title&amp;lt;/h3&amp;gt;
      &amp;lt;p&amp;gt;@snap.Value?.Description&amp;lt;/p&amp;gt;

      &amp;lt;DrylTimeline&amp;gt;
        @foreach (var step in snap.Value?.Steps ?? [])
        {
          &amp;lt;DrylTimelineItem Title="@step.Title"&amp;gt;@step.Text&amp;lt;/DrylTimelineItem&amp;gt;
        }
      &amp;lt;/DrylTimeline&amp;gt;
    &amp;lt;/DrylCard&amp;gt;
  &amp;lt;/ChildContent&amp;gt;
&amp;lt;/DrylAiGenerate&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5kbxw9krpn3ay62snldu.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5kbxw9krpn3ay62snldu.gif" alt="Structured Generation" width="559" height="396"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;snap&lt;/code&gt; is a &lt;code&gt;GenerationSnapshot&amp;lt;T&amp;gt;&lt;/code&gt;: on &lt;em&gt;every&lt;/em&gt; chunk you get the best-possible partial &lt;code&gt;Recipe&lt;/code&gt;. Under the hood a tolerant &lt;code&gt;PartialJsonReader&amp;lt;T&amp;gt;&lt;/code&gt; repairs the incomplete JSON on the fly — half-open strings, unclosed arrays, truncated objects. If a chunk lands mid-token and the parse fails, it simply holds the last good snapshot. &lt;strong&gt;No flicker, ever.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A few things I care a lot about here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's typed.&lt;/strong&gt; No &lt;code&gt;dynamic&lt;/code&gt;, no &lt;code&gt;JObject&lt;/code&gt; spelunking. &lt;code&gt;snap.Value&lt;/code&gt; is your &lt;code&gt;Recipe&lt;/code&gt;, IntelliSense and all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's just an &lt;code&gt;IAsyncEnumerable&amp;lt;string&amp;gt;&lt;/code&gt;.&lt;/strong&gt; No SDK lock-in in the component — in the docs the same demo runs against a canned replay &lt;em&gt;and&lt;/em&gt; a real local model via Ollama. Tests don't need a model at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The UI knows the AI is working.&lt;/strong&gt; Every DRYL component accepts a shared &lt;code&gt;AiState&lt;/code&gt; (&lt;code&gt;Thinking / Streaming / Generated&lt;/code&gt;). Pass &lt;code&gt;Ai="@snap.State"&lt;/code&gt; and the card wears a breathing gradient aura while streaming and plays a one-shot reveal when done. One vocabulary across the whole library — users &lt;em&gt;feel&lt;/em&gt; where the AI is at work without reading a label.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Progressive disclosure is trivial.&lt;/strong&gt; Wrap sections in &lt;code&gt;DrylPresence&lt;/code&gt; gated on the partial value (&lt;code&gt;Visible="@(snap.Value?.Ingredients is { Count: &amp;gt; 0 })"&lt;/code&gt;) and each part of the card animates in the moment the model produces it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The showstopper: &lt;code&gt;DrylAiCanvas&lt;/code&gt; — AI-built interactive artifacts
&lt;/h2&gt;

&lt;p&gt;Structured generation renders &lt;em&gt;your&lt;/em&gt; layout. The canvas goes one step further: &lt;strong&gt;the model designs the layout.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DrylAiCanvas&lt;/code&gt; gives your agent two tools — &lt;code&gt;create_artifact&lt;/code&gt; and &lt;code&gt;update_artifact&lt;/code&gt;. When the user asks for something visual, the model streams a component spec (a curated tree of DRYL components: charts, stat tiles, tables, inputs, buttons) and the canvas builds it &lt;strong&gt;node by node, live&lt;/strong&gt;, next to the chat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;DrylChat&amp;gt; ... &amp;lt;/DrylChat&amp;gt;

&amp;lt;DrylAiCanvas Run="_run"
              OnInteraction="i =&amp;gt; Send(i.ToPromptMessage())" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DrylCanvasTools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_run&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ChatClientAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;All&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fckhtiim872hkbksud5nl.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fckhtiim872hkbksud5nl.gif" alt="DRYL Canvas" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the part that makes it more than a pretty renderer: &lt;strong&gt;the artifact is interactive, and interactions round-trip to the model.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Buttons inside the artifact carry an &lt;em&gt;intent&lt;/em&gt;. When the user picks a region in the generated filter and clicks "Break down by region", you get a &lt;code&gt;CanvasInteraction&lt;/code&gt; — including the current values of every form control in the artifact. &lt;code&gt;interaction.ToPromptMessage()&lt;/code&gt; turns it into the next chat turn; the model answers with &lt;code&gt;update_artifact&lt;/code&gt;, streaming a &lt;em&gt;patch&lt;/em&gt;. Changed nodes glow, new nodes glide into place (FLIP-animated — nothing snaps), untouched nodes stay put.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxxbc56lq4swb3fv5xil6.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxxbc56lq4swb3fv5xil6.gif" alt="DRYL Canvas Interactive" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the loop is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;user asks → model builds UI → user clicks &lt;em&gt;inside that UI&lt;/em&gt; → model updates it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the "artifacts" experience you know from modern AI chat apps — as a drop-in Blazor component, running on your own agent and your own components.&lt;/p&gt;

&lt;p&gt;Safety-wise: the model never emits HTML or code. It emits a JSON spec validated against a &lt;strong&gt;curated component whitelist&lt;/strong&gt;. Invalid nodes never render — the model gets a corrective error string and retries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the iceberg
&lt;/h2&gt;

&lt;p&gt;These two are my favorites, but the AI layer runs deeper — all speaking the same &lt;code&gt;AiState&lt;/code&gt; vocabulary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DrylAiStream&lt;/code&gt;&lt;/strong&gt; — token streaming into &lt;code&gt;DrylMarkdown&lt;/code&gt; with a paced smooth mode (some providers deliver tool-call turns in one burst; this keeps it reading as a stream).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human-in-the-loop tools&lt;/strong&gt; — ready-made &lt;code&gt;AIFunction&lt;/code&gt;s that pop real dialogs mid-run (&lt;code&gt;AskChoice&lt;/code&gt;, &lt;code&gt;AskText&lt;/code&gt;, &lt;code&gt;RequestPermission&lt;/code&gt;) and &lt;em&gt;await&lt;/em&gt; the user's answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Display tools&lt;/strong&gt; — the model answers with live charts, stat rows and timelines inline in the conversation (&lt;code&gt;show_line_chart&lt;/code&gt;, &lt;code&gt;show_stats&lt;/code&gt;, …).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DrylHandoffTrace&lt;/code&gt;&lt;/strong&gt; — multi-agent sequential/concurrent flows rendered as a living timeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DrylAiField&lt;/code&gt;&lt;/strong&gt; — wrap any input, get an unobtrusive ✨ affordance: generate into an empty field, or transform just the selected text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And underneath it all sits a full general-purpose component library (60+ components): glass surfaces, token-driven dark/light modes, a fixed motion vocabulary, container-query responsiveness, zero npm dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Docs &amp;amp; live demos:&lt;/strong&gt; &lt;a href="https://components.dryl.dev" rel="noopener noreferrer"&gt;components.dryl.dev&lt;/a&gt; — the AI demos on the site run against real streams&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://github.com/Zimpi/DRYL.Components" rel="noopener noreferrer"&gt;github.com/Zimpi/DRYL.Components&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NuGet:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package DRYL.Components          &lt;span class="c"&gt;# core library&lt;/span&gt;
dotnet add package DRYL.Components.Agents   &lt;span class="c"&gt;# AI layer (Microsoft Agent Framework bridge)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDrylComponents&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;AddDrylAgents&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core is stable and dependency-free (Markdig only); the agents package is experimental and versioned independently while the Microsoft Agent Framework itself matures. You bring your own &lt;code&gt;AIAgent&lt;/code&gt; — OpenAI, Azure, or a local Ollama.&lt;/p&gt;

&lt;p&gt;If you're building anything AI-flavored in Blazor, I'd genuinely love feedback — especially on the canvas spec format and what components you'd want the model to be able to build. Issues and PRs welcome. ⭐&lt;/p&gt;

</description>
      <category>blazor</category>
      <category>dotnet</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
