CANVAKIT.md
source ↗CANVAKIT.md — template + data-source specification (v1, alpha)
Canvakit is an open standard for templates bound to data sources. A canvakit template is a single file with YAML frontmatter that declares named data sources and a body written in a pluggable template engine (Mustache by default). A canvakit runtime resolves the sources in parallel, merges the results into a rendering context, and emits the body — typically HTML, but the spec doesn't require a specific output format.
Canvakit's companion standard is
DESIGN.md (aka designkit).
DESIGN.md covers style — tokens and atomic components. CANVAKIT covers
structure + binding — composable templates that agents fill in, update, and
bind to tool outputs. The two are orthogonal and often used together: canvakit
templates consume designkit tokens through the optional @canvakit/designkit
bridge.
The reference implementation is @canvakit/core (Node/TypeScript). Canvakit
templates are portable: any runtime that can resolve tool names, read files, and
call a template engine can render them.
Why canvakit
An agent that needs to update a rendered UI with new data has two usual options: re-emit the whole HTML (expensive in tokens, fragile), or hand-wire a component library that knows how to refresh itself (heavy, framework-specific, non-portable). Canvakit gives a third option: render a template once, bind it to live data sources, re-render when the data changes. The template body is the same between updates; only the resolved data differs. Agents don't re-author HTML on every turn.
A canvakit template declares its tool dependencies. A runtime with those tools
available (Mastra, MCP client, Vercel AI SDK, a CI job with fixtures) can render
the template. The template is portable because the binding layer — kind: tool
references — is a normalized concept that every agent runtime speaks.
Document structure
A canvakit template has three parts:
- YAML frontmatter (required) — machine-readable declarations: identity, data sources, variables.
- Body (required) — the content, interpreted by the chosen template engine. In v1 the default engine is Mustache.
- Output — whatever the engine produces. HTML is the soft default (what v1 tooling assumes); non-HTML outputs are spec-legal.
Canonical filename
Canvakit templates use a double extension: <name>.canvakit.<engine-ext>.
q2-dashboard.canvakit.html— Mustache-templated HTMLbrief.canvakit.md— Mustache-templated markdownreport.canvakit.mdx— MDX-templated (Phase 3)
find . -name '*.canvakit.*' is the canonical glob. Editors autodetect the
template file type by the infix. The body engine is self-describing via the
final extension. The frontmatter template: true field is a redundant safety
check — tooling that loads files by glob can still catch "renamed but not a
template" mistakes.
The gallery + CLI resolve templates by the identity tuple
(author, name, version) from the frontmatter. The filename on disk is a
convention for humans and editors, not load-bearing for the registry.
YAML frontmatter
The frontmatter block must begin and end with lines containing exactly ---.
Everything between is parsed as YAML.
Schema
# ── Identity (registry + tooling) ─────────────────────────────
template: true # required marker
name: <string> # required; slug-safe
version: <semver> # required
description: <string> # optional; one-liner for gallery
author: <string> # optional; registry populates from publish identity
# ── Rendering ─────────────────────────────────────────────────
renderer: mustache # optional; default "mustache"
design: dk:heritage # optional; designkit hint — see below
# ── Refresh cadence (declarative; honored opportunistically) ──
refreshEvery: manual # manual | <duration> | on-tool-change
# ── Data ──────────────────────────────────────────────────────
variables: # authored defaults; overridable by caller
<string>: <any>
sources: # the binding layer
<name>:
kind: tool | static | file | query
# kind-specific fields — see §Data source kinds
design and forceDesign
Two frontmatter fields select a design. Both accept the same scheme- prefixed refs; they differ only in priority:
design:— fallback hint. Used only when no workspace design is active. Operator-set selection wins.forceDesign:— override. Bypasses workspace selection. The template author is declaring "this canvas must render against this specific design."
Accepted ref shapes (extensible via DesignSourceRegistry):
dk:<preset-id>— preset shipped with@agstudio/design-kit(e.g.dk:heritage,dk:modern-minimal).ws:<path>— workspace fs file (e.g.ws:DESIGN.md,ws:_design/foo.md).kit:<slug>— sugar forws:_design/<slug>.md../brand.md/brand.md/ any path ending in.md— back-compat: bare paths fall through to a workspace fs read.
Runtime callers may also force a design at the render call (e.g.
renderCanvas({ designOverride: "dk:neon-grid" })) — runtimes that support this
option pass it down to the bridge as the highest-priority ref.
When the @canvakit/designkit bridge is wired, design resolution priority is
(highest first):
- Runtime
designOverride(caller force at render time) forceDesign:frontmatter (template author force)- Workspace pointer —
_design/active.txt→_design/<slug>.md - Workspace root
DESIGN.md(Stitch drop-in) design:frontmatter hint (fallback)
The operator's workspace choice overrides the fallback hint — an active
DESIGN.md represents operator intent, not accident. Forces (1, 2)
intentionally bypass workspace selection: caller / author intent declares
"regardless of what's active globally, this canvas must use <X>." Templates that
want graceful degradation should still reference tokens from CSS via
var(--color-foo, <fallback>).
DesignSourceRegistry (resolver extensibility)
Schemes are pluggable. Apps and packages register custom schemes via
DesignSourceRegistry.register({ scheme, resolve }) — once registered, the
scheme works in design:, forceDesign:, and any runtime designOverride.
Built-ins ship with dk:, ws:, kit:. Common extensions: db:<slug>
(DB-backed kits), npm:@brand/kit (npm packages), https:// (URL-fetched
kits).
A resolver returns a DesignArtifact:
interface DesignArtifact {
tokens: DesignFrontmatter // CSS vars (mandatory)
tailwindExtend?: Record<string, ...> // optional Tailwind theme overrides
fonts?: Array<{ family, weights?, href? }> // optional explicit font imports
}
tokens always emits as :root { --color-* }. tailwindExtend (when present)
merges into the Tailwind bridge's default config so a design can ship its own
utility classes. fonts (when present) takes precedence over the auto-detection
from typography tokens.
refreshEvery
Declarative cadence hint. A runtime MAY honor it by scheduling re-renders;
runtimes that can't (browser playground, one-shot CLI) treat it as manual.
Accepted values:
manual(default) — never auto-refresh- duration string (
60s,5m,1h) — timer-driven refresh on-tool-change— re-render when anykind: toolsource's input invalidates (for agent runtimes with tool tracking)
Identity tuple
(author, name, version) is the canonical registry key. Templates published to
a canvakit gallery MUST carry all three. Local / in-workspace templates MAY omit
author — the identity is local to the workspace.
Data source kinds
Canvakit ships two primitives and two filesystem shorthands.
tool — primitive
weather: { kind: tool, ref: <tool-id>, params: { ... } }
Invokes a tool by name with JSON params. The portable binding — any runtime that
can resolve tool names can satisfy it. ref resolves in three styles, and
implementations SHOULD support all three:
| Style | Example | Runtime behavior |
|---|---|---|
| Flat id | searchFlights | Look up in the host's local tool registry |
| Namespaced | stripe.mrr, notion.pages.get | Longest-prefix match against registered wildcard resolvers |
| MCP-qualified | mcp://myserver/toolName | Proxy through MCP |
Resolution precedence: exact flat > longest wildcard > MCP. Params are passed through verbatim to the resolver; the returned value passes through verbatim into the context.
static — primitive
header: { kind: static, value: "Welcome back" }
Literal pass-through. Never fetched, never parsed. Useful for caller-supplied defaults and per-template constants.
file — shorthand for tool + readFile
roadmap: { kind: file, path: /plans/q2.md }
Read a single file from the render filesystem, parse by extension (see §Format
contract). A runtime without filesystem sugar MAY emit kind: file as
{ kind: tool, ref: "readFile", params: { path } } and implement the shape
conversion in its readFile tool — the observed value in the context is
identical.
The resolved value lands at the source name. For a markdown file, frontmatter
keys are flattened onto the resolved object alongside $body:
<h1>{{roadmap.title}}</h1> {{! frontmatter `title:` }}
<p>{{roadmap.owner}}</p> {{! frontmatter `owner:` }}
<article>{{{roadmap.$body}}}</article>
For a CSV: {{#roadmap.rows}}{{name}}{{/roadmap.rows}}. For JSON / YAML: the
parsed shape is exposed as-is under roadmap.
query — shorthand for tool + query
tasks:
kind: query
include: /tasks/**/*.md
where: { status: todo }
sort: -updatedAt
limit: 20
fields: [title, priority, assignee]
Fetch many files matching one or more globs; parse each by format; filter / sort / project.
include— glob pattern or array of patterns. A bare directory path (no glob char) is interpreted as<path>/**/*.mdfor back-compat with canvakit's pre-v1queryFileskind.where— field predicates on the parsed shape. Operators: equality,_in(array membership),_contains(substring),_before/_after(date or number compare).sort—field(asc) or-field(desc) on a parsed field.limit— max entries (default 50).fields— project into a subset of parsed keys.
Output: [{ path, data }] where data is the parsed content per the format
contract. The entry is the iteration unit, not the parsed shape itself —
templates address fields through data.<key>, not at the top level. path is
the source-relative file path (useful for links / debug).
{{#tasks}}
<li>
<a href="/{{path}}">{{data.title}}</a>
— {{data.assignee}} ({{data.priority}})
</li>
{{/tasks}}
For a markdown query, data is { ...frontmatter, $body } — frontmatter keys
are flattened on data, not nested under data.frontmatter. Use
{{data.title}}, never {{data.frontmatter.title}}. For CSV the per-entry
data is { columns, rows }; for JSON / YAML it's the parsed shape as-is.
Equivalent tool form:
{ kind: tool, ref: "query", params: { include, where, sort, limit, fields } }.
Per-source status
Every resolution reports a status — runtimes MUST surface this so authors can debug "empty render" cases without log-diving.
type SourceStatus =
| { status: "ok"; sampleKeys?: string[]; count?: number }
| { status: "missing"; reason: string }
| { status: "error"; reason: string }
A failed source degrades its own key (returns null / []); the render
completes with the remaining sources intact.
Deprecated kinds
kind: integration(pre-v1) — remapped tokind: toolwith the sameref. Accepted in v1 with a parse-time warning; removed in v2 (target: 2026-Q4).kind: queryFiles(pre-v1) — remapped tokind: query. Bare directory paths become<path>/**/*.mdfor continuity. Same deprecation cadence. Iteration shape changed: pre-v1 entries were{ path, frontmatter }; v1 entries are{ path, data }with frontmatter flattened ondata. Templates carrying{{frontmatter.x}}references must be updated to{{data.x}}— the kind rewrite alone does not touch the body.
Imports (composable canvases)
A template MAY declare an imports map in its frontmatter. Each entry is a
nested canvas that renders in isolation; the rendered body is exposed under
{{{imports.<name>}}} in the parent's context.
imports:
header:
template: parts/header.canvakit.html # no variables — pure partial
mrr:
template: widgets/mrr-card.canvakit.html # reusable widget, own sources
variables: { period: "Q2 2026" }
Semantics:
- Each import is a full nested render — its own frontmatter parse, its own
sourcesresolution, its own engine pass, its own_datapayload. - Imports inherit the parent's filesystem, tool registry, and render
extensions (so
@canvakit/designkitthemes children automatically). - Imports do not inherit the parent's
variablesor resolved sources — scope is deliberately isolated. Pass values down explicitly via each entry'svariables:block. - Per-source statuses from an imported canvas roll up into the parent's
statusesmap under<importName>.<sourceName>. - Cycle protection: a template path already in the active import chain is rejected with a warning and renders as the empty string.
- Depth cap: at least 8 levels of nested imports. Runtimes MAY cap at a lower value.
- Reference the rendered body with triple-brace:
{{{imports.mrr}}}. Double-brace would HTML-escape the rendered HTML.
Use cases
- Shared layout fragments (header, footer, nav) — imports with no variables and no sources act as partials.
- Reusable widgets (a stripe-mrr-card, a flights-card) — self-contained canvases composed into dashboards. Each re-renders against fresh tool data when its ref is re-invoked; the parent doesn't need to know.
- Base layouts — a parent template with slots; child templates fill the body.
Token economics
A composed dashboard ships with thin frontmatter + a body that references
{{{imports.*}}}. The agent authoring the dashboard doesn't re-emit every
widget's HTML; the import reference is a few tokens. Re-running a widget's
underlying tool re-renders just that widget's output, not the whole page.
Format contract
Both file and query parse a file's content by its extension into a uniform
shape. Canvakit runtimes MUST implement these mappings so templates see the same
data regardless of source format:
| Extension | Parsed shape |
|---|---|
.json | Parsed JSON (any shape) |
.yaml / .yml | Parsed YAML (any shape) |
.csv / .tsv | { columns: string[], rows: Record<string, string>[] } |
.md / .markdown | { ...frontmatter, $body: string } |
.html / .htm / .txt | { $text: string } (escape-safe raw text) |
| (anything else) | { $text: string } |
A template rolling up tasks from markdown frontmatter and pricing from a CSV
uses the same kind: query with two include patterns — the parsed shapes
differ per entry but the iteration pattern is identical.
Context shape
The render pipeline merges resolved sources into a context object and passes it to the engine. Precedence (lowest to highest):
- Template-declared
variablesdefaults - Caller-provided
variablesoverrides - Resolved
sources(wins — the dynamic data is the point)
Plus these well-known roots:
$meta—{ renderedAt, templatePath, renderedFrom, instanceSlug }$design— flattened designkit tokens when the optional designkit bridge is wired (e.g.{{$design.colors.primary}}).nullwhen no design is active. Authoring convention: reference tokens from CSS viavar(--color-foo, <fallback>)rather than emitting a competing:root { ... defaults ... }block in the template body — the bridge's injected:rootcan't override a later-in-body:rootdeclaration (CSS cascade), and inlinevar()fallbacks render cleanly whether the bridge is wired or not._data— entire context serialized as<script>-safe JSON. See §Client-side rehydration.renderedAt— alias for$meta.renderedAtexposed at the top level for older templates.
A caller-provided variables key that shadows a sources name is silently
replaced by the source (the dynamic value wins). Runtimes SHOULD emit a warning
so the collision is visible.
Client-side rehydration
Canvakit produces server-rendered content AND an embedded JSON copy of the same context, so the browser can rehydrate typed values without re-fetching or re-computing, without prescribing a frontend framework:
<script id="canvas-data" type="application/json">
{{{_data}}}
</script>
<script type="module">
const ctx = JSON.parse(document.getElementById("canvas-data").textContent)
// ctx.flights.flights is typed exactly as the tool returned it —
// draw sparklines, animate counters, filter a table client-side
// without duplicating logic in SQL or a second fetch.
</script>
The triple-brace {{{_data}}} skips the engine's default HTML escaping (because
_data is already safe JSON — < in string values is pre-escaped to < to
neutralize stray </script> sequences). React islands, Solid signals, Vue refs,
and any other opinionated hydration contract build on this primitive.
Tool registry
A canvakit runtime hosts a tool registry that resolves kind: tool refs. The
reference implementation exposes one function:
registerTool(match, resolver)
where match is one of:
"searchFlights"— exact flat id"stripe.*"— namespaced wildcard (longest-prefix match)"mcp://myserver/*"— MCP prefix
Resolution precedence is exact flat > longest wildcard > MCP. Registering the
same match twice replaces the previous resolver (supports hot reload in dev).
Portability
A canvakit template with only kind: tool + kind: static entries is portable
to any runtime that implements the tool registry. The frontmatter becomes a
declaration of tool dependencies:
sources:
flights: { kind: tool, ref: searchFlights, params: { ... } }
mrr: { kind: tool, ref: stripe.mrr, params: { ... } }
Render under Mastra → its tool registry resolves searchFlights; a registered
stripe.* handler resolves stripe.mrr.
Render under a Vercel AI app → the tools array supplies both.
Render under an MCP-speaking runtime → both proxied through MCP.
Render in CI with fixtures → each ref resolves to a mock.
Same template, different runtime, correct render.
The file / query shorthands exist because simple filesystem-driven templates
are the 80% case. A bare-bones renderer (filesystem + Mustache, no tool runtime)
handles them natively; a tool-runtime-only renderer maps them through the
readFile / query well-known tools.
Streaming — v1 limitation
Tools that stream incremental results (long-running search, multi-step agent
runs) are resolved fully before render in v1. Templates expecting progressive
rendering SHOULD use refreshEvery or on-tool-change to re-render after each
update. True streaming bodies (render-as-the-tool-streams) are out of scope for
v1.
Output
Whatever the engine produces. HTML is the soft default — the v1 Mustache
engine, the v1 playground, the v1 gallery preview, and all example templates in
this spec assume HTML. Non-HTML templates (foo.canvakit.svg,
foo.canvakit.xml, foo.canvakit.csv) are spec-legal but outside v1 tooling's
polished path; runtimes MAY refuse them or render without preview.
Engine contract
A canvakit engine is a single function: (body, context) => Promise<string>. No
class hierarchy, no lifecycle. An MDX engine (Phase 3) and a Handlebars engine
both fit this shape. A canvakit core implementation MUST NOT import React, DOM,
or any front-end framework; engines that need React import it themselves in a
separate package.
Unknown content handling
Per the consumer-behavior convention inherited from DESIGN.md:
| Scenario | Behavior |
|---|---|
| Unknown frontmatter key | Preserve (round-trip); do not error. |
Missing template: true marker | Warn; render anyway. |
Unknown sources kind | Drop with warning; other sources still render. |
| Malformed source fields | Drop with warning; status reports the issue. |
Missing / unreadable file for file source | Source status becomes missing; value is null. |
| Unregistered tool ref | Source status becomes missing; value is null. |
Unknown refreshEvery value | Warn; fall back to manual. |
Runtimes SHOULD NOT crash a render over author / data mistakes — surface per-source statuses and carry on.
Versioning
This spec is v1, alpha in 2026-04. Breaking changes land in v2 (target:
2026-Q4):
kind: integration— removedkind: queryFiles— removed- Stricter
refreshEveryparsing
Additions (new kinds, new format mappings, new context roots) are non-breaking and land as minor updates to v1.
The reference implementation — @canvakit/core + @canvakit/designkit — lives
in the agentik-studio monorepo. The
public gallery + playground + CLI live at canvakit.sh.