Skip to content

Pages & templates

The page is the visual half of a frame: a tree of blocks, fed by expressions, rendered over the frame’s view. Templates package reusable blocks, expressions, and workflow executions. Instantiate visual content with @block/use or @expr/use; instantiate workflow logic with a configured workflow’s use field.

Pages

A page is a block tree rooted at @block/page. Layout blocks (@block/stack, @block/grid, @block/box, @block/tabs) arrange the visualization and input blocks inside them, and the whole tree is wrapped automatically so page-wide behaviour — a shared time range, a synchronized crosshair, active filters — reaches every block without per-block wiring.

Because blocks query the frame’s view by name, the page stays in step with its data model: change the view’s scoping or add a metric, and the blocks that reference it follow. A single frame can also carry more than its main page — standalone pages present extra views over the same frame’s data.

Templates

When the same piece of a page shows up more than once — a status badge, a formatted label, a small chart — you don’t copy it. You define it once as a template and instantiate it wherever you need it with @block/use (for blocks) or @expr/use (for expressions).

Defining a template

Templates live under a templates map, split by kind into blocks, expressions, and workflows. Workflow templates wrap an execute body; see workflow templates. Names only need to be unique within their kind, so a block and an expression may share a name. There are two forms:

  • Reference — a plain alias to a fixed block or expression, instantiated as-is each time:

    {
    "templates": {
    "blocks": {
    "ok_badge": { "reference": { "@block/badge": { "title": "OK" } } }
    }
    }
    }
  • Partial — a parameterized template. It declares a JSON Schema for the parameters it accepts, and the template reads them from context:

    {
    "templates": {
    "blocks": {
    "greeting": {
    "partial": {
    "parameters": {
    "type": "object",
    "properties": { "name": { "type": "string" } },
    "required": ["name"]
    },
    "template": { "@block/text": { "text": { "@expr/get_context": "name" } } }
    }
    }
    }
    }
    }

Using a template

Reference a template by name with @block/use (or @expr/use). A bare string is the shorthand for a no-parameter reference; the full form passes params:

{ "@block/use": "#/block/ok_badge" }
{ "@block/use": { "ref": "#/block/greeting", "params": { "name": "checkout" } } }

For a partial, the params are validated against the template’s schema, then bound into the template’s context — which is why the template above reads name with @expr/get_context. Instantiating a template is, in effect, wrapping it in a @block/context that supplies those parameters.

Two things follow from that, both of which bite the first time you extract a template that renders more than once on a page:

  • A shared template declares no id. An id is view-unique, so a template carrying a literal one would declare the same name at every call site, and the duplicate is reported as a node error. Name the subtree at the instantiation site instead — @block/use takes its own id, which aliases the use site rather than anything inside the template:

    {
    "@block/use": {
    "ref": "#/block/breakdown_chart",
    "params": { "measure": "MemUsedBytes" },
    "id": "memory_chart"
    }
    }

    id is only available on the object form. It names the use site rather than anything the template renders, so the instance’s state and errors are reported against that name rather than an opaque positional path — the same alias a visualization’s id registers.

  • params are validated as authored, before anything resolves. A parameter passed an expression — a binding the template writes back through, a value picked per call site — is an object at that point, so its declared type has to admit one ({ "type": ["string", "object"] }). Declare the plain type alone and the expression is rejected at the call site.

  • params supply runtime context values. @expr/get_context resolves a parameter before the consuming block interprets it, so a parameter cannot generate authored syntax such as a column identifier. For @block/stat with from, keep value as a literal column name in the template. To vary the reading, pass a resolved scalar and omit from, or normalize each source to the same fixed column name.

Where templates live

The ref string says where to look:

  • Inline — #/block/name resolves a template defined in the templates map of the file it’s written in. In a frame that’s the frame’s own map; inside a shared *.templates.json, a template body’s #/ refs resolve against that file’s own map, regardless of which frame renders it.

  • Shared file — @frames/path/to/file.templates.json#/block/name resolves a template from a dedicated *.templates.json bundle, reusable across many frames. A file part that does not start with @ resolves against the directory of the file the ref is written in, so ./cards.templates.json#/block/name names a bundle in the same directory.

  • Packs — integrations ship template bundles this way. The OpenTelemetry pack, for example, provides shared badge templates that any OTel dashboard can drop into a table column:

    { "@block/use": "@frames/@opentelemetry/badges.templates.json#/block/severity_badge" }

Templates vs. embedding a frame

Templates and @block/frame both reuse authored content, but at different scales:

  • Reach for a template (@block/use / @expr/use) to reuse a UI component — a badge, a label, a chart — without re-declaring it.
  • Reach for @block/frame to borrow another frame’s view and data model rather than just a snippet of page. Its child renders against that frame; supply it either as block or inline as a @block/… key.

Seeding context: time ranges and borrowed views

@block/context creates a scope for everything beneath it. Besides the extend map (named bindings descendants read via @expr/get_context), it takes two shortcuts:

  • timerange — seed the time window for the subtree, a from bound and an optional to (defaulting to now). Descendant queries pick it up automatically:

    {
    "@block/context": {
    "timerange": { "from": "now-7d" },
    "block": { "@block/use": "#/block/error_chart" }
    }
    }
  • frame — borrow another frame’s view for the subtree (its _frameRuntime, params, and filter scope), the same binding @block/frame injects, without rendering anything of that frame’s own route. A bare string is shorthand for { "id": … }; use the object form to pass params:

    { "@block/context": { "frame": "services", "@block/table": { … } } }
    { "@block/context": { "frame": { "id": "services/{ServiceName}", "params": { "ServiceName": "checkout" } }, "@block/table": { … } } }

A bound (from / to) is either a relative offset object ({ "value": -15, "unit": "minutes" }), date math (now, now-2d, now-1d/d — offsets plus an optional /unit rounding), or an ISO-8601 timestamp (2026-05-30T00:00:00Z). The same bound syntax applies wherever a time window is authored (e.g. a frame’s settings.default_timerange).

Customizing an installed frame

Frames installed by an integration are, by default, managed — a reconcile overwrites the file to match source, so editing it in place loses the edit on the next noemata up. Two ways to change one without that risk:

Extend by reference

Add a sibling frame that references the installed one and layer your changes on top, rather than editing it:

  • Add the installed frame to a view’s imports to inherit its scalars and scope — see Views.
  • Pull a specific block in with @block/use.

The frame schema reference has the full imports and reference surface. (Referencing still keeps your changes cleanly separated from upstream even on a user-owned pack, where editing in place is safe.)

.local and .overrides overlays

An overlay retunes a frame in place without forking it: drop index.frame.local.json beside index.frame.json (or index.page.local.json, overview.templates.local.json, alerts.workflows.local.json), and the frame renders as base ⊕ local — the overlay replaces or adds the fields it names, and everything else is inherited from the base:

{
"$schema": "../../../.data/schemas/frame.overrides.json",
"title": "My services",
"view": {
"tables": { "logs": { "table": { "from": "otel_logs", "with": { "Sev": "SeverityText" } } } }
}
}

Every field is optional, and an overlay only ever adds or replaces — it can retitle the document, add or retune view tables and scalars, or add named templates, but it can never delete a base field. Each overlay layers onto the file it names, so a *.frame.local.json retunes the view and a *.page.local.json retitles the route or swaps its page. Editing an overlay hot-reloads the frame, and it’s JSON-Schema validated and autocompleted in the editor. An overlay with no base file beside it is a validation error rather than a silent no-op.

.local and .overrides are the same file shape and the same merge rules, differing only in who they’re for:

  • .local (index.frame.local.json) is yours: gitignored, so it never lands in a commit and a personal tweak survives the base being re-stamped.
  • .overrides (index.frame.overrides.json) is your team’s: committed and shared even inside a gitignored managed pack, so a shared customization of a shipped dashboard lives in version control while reconcile keeps the base matching source underneath it.

Neither is ever touched by reconcile — it only considers files the pack ships or previously installed, so an overlay you add is never overwritten or swept as stale. Local roots render as base ⊕ overrides ⊕ local: .overrides layers the team’s customization first, and a personal .local overlay wins on top of that.

Workflows files overlay the same way, per workflow name: each field an overlay entry names (title, enabled, schedule, timeout, execute) replaces the base’s wholesale — execute as one unit — and a new name adds a complete local workflow. The canonical uses are enabling a pack-shipped reference workflow, tightening its execution deadline, and muting a noisy one on your machine:

{ "workflows": { "service_error_rate": { "enabled": true } } }

Overlay the narrowest file that owns what you’re changing. page replaces wholesale, so a .page.overrides.json that touches it re-authors the layout, every panel, and every section the page declares. Where a pack keeps its panels in a *.templates.json — the shipped integrations all do — a <name>.templates.overrides.json replaces one named template and leaves the rest of the page upstream, so the panel keeps receiving improvements you didn’t override. A views/*.frame.overrides.json sits lower still: it retunes a scalar for every frame that imports that view.

Next

Shared roots resolve dependencies against shared originals and .overrides layers. Local roots may use local or shared dependencies. A shared template or view cannot reference a .local file anywhere in its dependency chain. To preview a local view change through a page, make the page a local variant as well. A .local directory marks every file below it as local; .overrides inside that directory stays local.