canvakit.sh

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:

  1. YAML frontmatter (required) — machine-readable declarations: identity, data sources, variables.
  2. Body (required) — the content, interpreted by the chosen template engine. In v1 the default engine is Mustache.
  3. 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 HTML
  • brief.canvakit.md — Mustache-templated markdown
  • report.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 for ws:_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):

  1. Runtime designOverride (caller force at render time)
  2. forceDesign: frontmatter (template author force)
  3. Workspace pointer — _design/active.txt_design/<slug>.md
  4. Workspace root DESIGN.md (Stitch drop-in)
  5. 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 any kind: tool source'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:

StyleExampleRuntime behavior
Flat idsearchFlightsLook up in the host's local tool registry
Namespacedstripe.mrr, notion.pages.getLongest-prefix match against registered wildcard resolvers
MCP-qualifiedmcp://myserver/toolNameProxy 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>/**/*.md for back-compat with canvakit's pre-v1 queryFiles kind.
  • where — field predicates on the parsed shape. Operators: equality, _in (array membership), _contains (substring), _before / _after (date or number compare).
  • sortfield (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 to kind: tool with the same ref. Accepted in v1 with a parse-time warning; removed in v2 (target: 2026-Q4).
  • kind: queryFiles (pre-v1) — remapped to kind: query. Bare directory paths become <path>/**/*.md for continuity. Same deprecation cadence. Iteration shape changed: pre-v1 entries were { path, frontmatter }; v1 entries are { path, data } with frontmatter flattened on data. 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 sources resolution, its own engine pass, its own _data payload.
  • Imports inherit the parent's filesystem, tool registry, and render extensions (so @canvakit/designkit themes children automatically).
  • Imports do not inherit the parent's variables or resolved sources — scope is deliberately isolated. Pass values down explicitly via each entry's variables: block.
  • Per-source statuses from an imported canvas roll up into the parent's statuses map 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:

ExtensionParsed shape
.jsonParsed JSON (any shape)
.yaml / .ymlParsed 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):

  1. Template-declared variables defaults
  2. Caller-provided variables overrides
  3. 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}}). null when no design is active. Authoring convention: reference tokens from CSS via var(--color-foo, <fallback>) rather than emitting a competing :root { ... defaults ... } block in the template body — the bridge's injected :root can't override a later-in-body :root declaration (CSS cascade), and inline var() 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.renderedAt exposed 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:

ScenarioBehavior
Unknown frontmatter keyPreserve (round-trip); do not error.
Missing template: true markerWarn; render anyway.
Unknown sources kindDrop with warning; other sources still render.
Malformed source fieldsDrop with warning; status reports the issue.
Missing / unreadable file for file sourceSource status becomes missing; value is null.
Unregistered tool refSource status becomes missing; value is null.
Unknown refreshEvery valueWarn; 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 — removed
  • kind: queryFiles — removed
  • Stricter refreshEvery parsing

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.