DrylCanvas
AI-awareDrylCanvas renders a CanvasSpec — a titled tree of curated
DRYL components — as a live surface. It is deliberately dumb: a spec goes in, interactions
come out. Where the spec came from is nobody's business but the host's: code, a database,
a saved document, or an AI generation (that last one is
DrylAiCanvas, which wraps this component).
Data binding
A node can carry a data binding instead of literal values:
{ "source": "sales.byMonth", "params": { … }, "refresh": "interval:30s" }.
The host registers named sources; the canvas resolves them, dedupes identical
source-plus-params into a single call, and refreshes without asking anyone.
The result is that numbers stop being prose. An AI-authored artifact no longer has to write them into its brief — it references a source, the model never sees a single row, and the values stay current long after the generation finished.
Every refresh is a movement, not a rebuild. The first load shows a skeleton; after that the node keeps its identity and a changed value arrives as the same change-pulse and count-up an AI patch produces. A refresh that changes nothing renders nothing at all — a dashboard on a 30-second interval must not blink.
A dashboard bound to host data
Invalidate — the bound nodes
move, they are not rebuilt.
Registering a source
A source is a name, one sentence for the model, and a handler. Its parameters are a C# record — the record is the schema, so there is no prose description to keep in step and an unsupported parameter type fails at startup rather than when the model happens to find it. Tenant and user come from the handler's DI scope, never from the spec, so a generated artifact cannot reach data it should not see.
public sealed record SalesParams(int Year, string? Region = null);
builder.Services.AddDrylCanvasDataSource("sales.byMonth",
"Revenue per month in €k.",
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)));
});
// A source with no parameters at all.
builder.Services.AddDrylCanvasDataSource("orders.open", "The open orders.",
(CanvasDataContext ctx, CancellationToken ct) =>
Task.FromResult(CanvasData.Rows(columns, rows)));Refreshing
Three ways, all of them the host's or the user's call — never the model's:
the declarative refresh: "interval:<n>s" (one timer per canvas,
five-second floor), the ↻ button that appears in the header as soon as
the artifact has a binding, and ICanvasDataService.Invalidate when the
host knows something moved. A { "$field": "region" } parameter adds a
fourth path for free: a select at the top of the artifact, and the nodes that depend
on it follow — debounced, and without a model turn.
@inject ICanvasDataService Data
// Everything bound to this source reloads …
Data.Invalidate("orders.open");
// … or only the bindings whose parameters match.
Data.Invalidate("sales.byMonth", new { year = 2026, region = "North" });Actions — the write side
Data binding lets an artifact read. An action lets it write: a button binds to a registered host command instead of turning the click into a chat message. The handler is ordinary C# with a DI scope and typed arguments, and its result tells the canvas what to do next — show a message, reload the affected sources, patch a node.
The AI builds the button. It never presses it. There is no
run_action tool and no path from a model output to a handler; the only
caller is the click. That is a property of the architecture, not a promise in a prompt —
and it is why an artifact may safely offer "Release order" at all.
Buttons wired to host commands
The catalog
Thirty node types, all of them mapped onto ordinary DRYL components — a spec can never render anything the library does not already ship. The ones a real line-of-business screen reaches for first:
dataGrid is the interactive big brother of table: bind it
to a rows source and it sorts, filters, searches and pages, up to a thousand rows.
table stays for the small static case. kpi is a compact
row of stats, list and keyValue take the same rows sources
(a keyValue wants exactly two columns), and accordion
folds detail away the way tabs puts it side by side.
code, image and emptyState cover the
remaining content shapes — an artifact with nothing to show says so, rather than
rendering an empty card.
form is the one that changes how you write an artifact.
It is a container whose action sits on the container itself: the interactive nodes
inside it become one command with one submit button, and required
fields are checked before the handler is ever called. A generated screen no longer
needs a button per field — and the guarantee from actions still holds, because
submitting is still something only a person does.
An order cockpit
Registering an action
Same shape as a source, deliberately: a name, one sentence for the model, and a handler
whose arguments are a C# record. A { "$field": "order" } argument reads the
live value of an interactive node, so the button acts on what the user selected.
public sealed record ApproveArgs(string OrderId, string? Note = null);
builder.Services.AddDrylCanvasAction("order.approve",
"Releases an order.",
async (ApproveArgs a, CanvasActionContext ctx, CancellationToken ct) =>
{
await ctx.Services.GetRequiredService<IOrderService>().ApproveAsync(a.OrderId, ct);
return CanvasActionResult.Ok("Order released")
.Refresh("orders.open", "orders.openCount");
});
// In the artifact, next to "props" — never inside them:
// { "id": "approve", "type": "button",
// "props": { "label": "Release", "kind": "danger" },
// "action": { "name": "order.approve",
// "args": { "orderId": { "$field": "order" } },
// "confirm": "Really release this order?" } }
//
// On a form the action sits on the container, and the submit button is implicit:
// { "id": "create", "type": "form",
// "props": { "submitLabel": "Create order", "required": ["customer"] },
// "action": { "name": "order.create",
// "args": { "customer": { "$field": "customer" } } },
// "children": [ /* inputText, select, … */ ] }
Success is a toast — the artifact refreshes visibly anyway, so the message may pass.
A failure stays inline at the button, because it asks the user to do something and must
not expire. confirm puts a DrylDialog in front of the handler;
without a DrylDialogProvider the action is refused rather than run
unconfirmed. AskAi(…) is opt-in and reaches your existing
OnInteraction wiring unchanged.