Skip to content

Dashboard YAML Syntax Reference

This page provides a detailed reference for the Dashboard YAML syntax, designed for AI-powered dashboard generation.

A dashboard is a YAML-authored, independent top-level entity — its own admin section, its own storage — made of tiles (table / stat / bar_chart / scatter / line_chart), each backed by an existing connector data_processor or the native asset registry, and parameterized at view-time by dashboard controls (filter widgets). Dashboards share the approvalml package/parser with workflows, reusing data_processor:/params:/join: and the section grid/columns layout model verbatim — but they parse and store as a completely separate document type, discriminated by kind:.

Dashboards are named dashboards (plural) everywhere — the API is mounted at /dashboards, the admin UI lives under /admin/dashboards — to avoid colliding with the pre-existing singular /dashboard home-page (“pending approvals / my submissions”) endpoints.

kind: dashboard # Required — distinguishes this file from a workflow YAML
name: "Vendor Spend Overview"
description: "Quarterly vendor spend by region"
view_roles: ["finance_manager", "admin"] # Optional — company_roles allowed to view
controls: [] # Optional — filter widgets, see below
tiles: [] # Required, at least one
layout: # Required — placement, reuses workflow form layout
sections: []
subscriptions: [] # Optional — see "Subscriptions" below

kind: dashboard is required and has no default — unlike a workflow file, where kind defaults to "workflow" when omitted, so none of the existing workflow YAML files needed to change. A file missing kind: is rejected as a dashboard.

Dashboard-level filter widgets. A tile’s data_processor.params[] reads a control’s resolved value via from_control: <name> (see “Data Processor Parameters” below).

controls:
- name: date_from
label: "From" # Optional — display text; falls back to `name` when omitted
type: date
- name: date_to
label: "To"
type: date
- name: pillar
label: "Pillar"
type: select # static options, no dynamic discovery
options:
- { label: "Growth & Web", value: "growth" }
- { label: "Systems & Security", value: "systems" }
- name: severity
label: "Severity"
type: multi_select # resolves to a list
options:
- { label: "High", value: "high" }
- { label: "Critical", value: "critical" }
- name: timeframe
label: "Timeframe"
type: date_range_preset # resolves server-side into {name}_from / {name}_to
options:
- { label: "Last 7 Days", value: "last_7_days" }
- { label: "Last 30 Days", value: "last_30_days" }
- { label: "This Quarter", value: "this_quarter" }
Type Description
date A single date picker.
select Dropdown, one value, from static options.
multi_select Dropdown, multiple values, from static options.
date_range_preset Dropdown of named presets; each options[].value must be one of the known preset keys below.

controls[].name must be unique within a dashboard — it’s the wire identifier from_control: references, so keep it a stable slug and never rename it once a tile depends on it. label is optional human-facing display text shown next to the widget; when omitted, the viewer falls back to showing name directly, which reads as a raw slug (“date_from”) rather than a proper filter label — set label on any control shown to end users. select/multi_select/date_range_preset all require a non-empty options list.

last_7_days, last_30_days, this_month, last_month, this_quarter, last_quarter, this_year. An options[].value outside this set is rejected at validation time.

When a control of this type is applied, it expands server-side into two derived control values before any tile’s params are resolved: {control_name}_from and {control_name}_to (ISO date strings). A tile’s from_control: can target either the raw preset name or the resolved _from/_to bound directly:

data_processor:
source_name: "Vendor Spend SQL"
params:
- name: date_from
from_control: timeframe_from # resolved bound, not the raw "last_30_days" string
- name: date_to
from_control: timeframe_to

controls[].default pre-fills a control before the dashboard’s first render — without it, every control starts unset, and the viewer sees {} for control_values until they pick something and click Apply.

controls:
- name: date_from
type: date
default: today # or a literal ISO date, e.g. "2026-01-01"
- name: pillar
type: select
default: growth # must be one of this control's own options[].value
- name: timeframe
type: date_range_preset
default: last_30_days # must be one of this control's own options[].value

Set a default on every control any tile reads via from_control for a connector param that’s actually required — otherwise the dashboard errors on first load, before the viewer has touched anything: the param gets resolved from an empty value, and most connectors reject that outright (or, worse, a {{placeholder}} in the connector’s own config is left unsubstituted and sent through literally). A control with no default is a deliberate choice to force the viewer to pick a value first — the studio and the viewer both treat it that way: the tile just doesn’t execute until the control is filled, rather than firing with it missing.

multi_select accepts a YAML list as its default (each element must be one of options[].value).

Every tile is one flat object discriminated by type. Each tile needs exactly one of data_processor or source — never both, never neither.

tiles:
- id: top_vendors # Required, unique within the dashboard
type: table # table | stat | bar_chart | scatter | line_chart
label: "Top Vendors" # Optional display title
view_roles: ["admin"] # Optional — narrows Dashboard.view_roles for this one tile
data_processor: { ... } # One of data_processor / source, see below
# source: { type: asset, category: server }
row_path:
"data" # JSONata — which array in the response is "the rows"
# unused when source: {type: asset} (see below)
filter: "..." # Optional JSONata predicate, valid on every tile type
Field Applies to Description
id all Unique tile identifier, referenced from layout.sections[].grid/.columns.
type all table | stat | bar_chart | scatter | line_chart.
label all Display title.
view_roles all Optional — narrows Dashboard.view_roles for this one tile. Omit to inherit the dashboard’s own view_roles; a dashboard admin or its creator can always see every tile regardless. A viewer who can open the dashboard but not this tile never receives it in the tile list at all.
data_processor all except native-asset tiles Same shape as a workflow automatic step’s data source config — source_id/source_name, params, join.
source asset-backed tiles only { type: asset, category: <str> } — see “Native Asset Source” below.
row_path all except source: asset JSONata expression selecting the row array out of the raw response.
filter all JSONata predicate over the whole rows array, applied after row extraction and join enrichment, before type-specific processing.

The raw connector response is always wrapped as {"data": <response>} before row_path runs, even when the connector already returns a bare row list. So the row array lives at data, never at $ ($ here means the {"data": ...} wrapper itself, not its contents):

row_path: "data" -> correct: the row array
row_path: "$" -> WRONG: returns the {"data": [...]} wrapper object, not the rows

This fails silently, not loudly — for table/line_chart tiles that build the row array with an inline group-by (data {...} ~> $each(...)), starting from $ {...} instead of data {...} evaluates to an empty result, so the tile just renders with no rows and no error. For other tile types, the wrapper object gets treated as a single bogus row instead. Always start row-array-building expressions from data, not $.

- id: top_vendors
type: table
data_processor:
source_name: "Vendor Spend SQL"
row_path: "data"
columns:
- { label: "Vendor", path: "vendor.name" } # JSONata, evaluated per row
- { label: "Total Spend", path: "totals.amount" }

columns is required and non-empty — each entry is { label, path }, path a per-row JSONata expression.

A column value that isn’t plain text or a number — an object, or an array from a join’s as_array: true — renders as compact JSON (e.g. {"id":123,"name":"Acme"}) rather than plain text, so it stays readable instead of showing as raw text or losing its structure.

Renders as a KPI card, chosen by template: — templates can be added over time without changing the dashboard schema. metric/compare_metric/format aren’t fixed stat-tile fields; they’re defined entirely by the chosen template’s own settings schema (each template validates its own settings shape).

- id: total_spend
type: stat
template: kpi_badge # Required — must exist in the template registry
data_processor:
source_name: "Vendor Spend SQL"
row_path: "data"
settings: # Validated against kpi_badge's OWN schema
layout: badge # inline | badge | value_first | historical
format: currency # number | currency | percent
metric: "totals.amount"
agg: sum # shorthand for $sum(rows.totals.amount)
compare_metric: "totals.prior_period_amount" # optional, same row set, no second query
compare_label: "vs last month"
higher_is_better: true # default true; flips change_type polarity when false

The v1 template registry ships exactly one template, kpi_badge:

Setting Required Description
layout No (default inline) inline | badge | value_first | historical — presentation only, never changes the computed numbers.
format No (default number) number | currency | percent.
metric Yes JSONata expression (whole-set {"rows": [...]} context, or a plain field path when agg: is set).
agg No sum | avg | count | min | max — shorthand: when present, metric must be a plain per-row field path, reduced in Python.
compare_metric No Same shape as metric, evaluated against the same row set — no second query.
compare_label No Text shown next to the computed delta, e.g. "vs last month".
higher_is_better No (default true) Set false so an increase renders as a negative-styled change (e.g. rising error counts).

change_amount/change_pct/change_type are always computed server-side from metric/compare_metric — never authored directly in YAML.

Additional templates can be added later without any change to the dashboard YAML schema or its validation rules.

- id: spend_by_region
type: bar_chart
data_processor:
source_name: "Vendor Spend SQL"
row_path: "data"
x: "vendor.region.code" # per-row JSONata — the grouping key
y: "totals.amount" # plain field, aggregated via agg: below
agg: sum # sum | avg | count | min | max — only used when y: is a plain field

Omit agg: to evaluate y as a full JSONata expression per x-group instead of a plain-field reduction — rows are first bucketed by x, then y is evaluated against each bucket’s own {"rows": [...]} context:

- id: net_spend_by_region
type: bar_chart
data_processor:
source_name: "Vendor Spend SQL"
row_path: "data"
x: "vendor.region.code"
y: "$sum(rows.totals.amount) - $sum(rows.totals.refund_amount)" # per-group JSONata — note the rows. prefix

x and y are both required.

Unaggregated — every row becomes one point, no grouping.

- id: error_vs_delay
type: scatter
data_processor:
source_name: "Audit Findings SQL"
row_path: "data"
x: "error_rate" # per-row JSONata
y: "sla_delay_hours"
size: "dollar_risk" # optional — bubble radius source
point_label: "vendor_name" # optional — per-point tooltip field
quadrants: # optional threshold lines + region labels
x_threshold: 5.0
y_threshold: 24
labels:
high_high: "Critical Process Failure"
high_x_low_y: "Fast but Sloppy"
low_x_high_y: "Accurate but Constricted"
low_low: "Healthy"

x and y are required. A row missing size falls back to a default radius rather than erroring.

Multi-series comparison sharing one x — e.g. “Approved vs. Rejected count, by day”. Each series is a plain per-row field path off the same row x came from; no separate fetch, no re-grouping.

- id: approved_vs_rejected_trend
type: line_chart
label: "Approved vs. Rejected, by Day"
data_processor:
source_name: "Aptiwise Internal DB"
# SQL aggregates in the query, already wide-format, one row per day:
# SELECT date_trunc('day', created_at) AS day,
# count(*) FILTER (WHERE status='approved') AS approved,
# count(*) FILTER (WHERE status='rejected') AS rejected
# FROM approval_instances GROUP BY 1 ORDER BY 1
row_path: "data"
x: "day" # per-row, shared across all series
series:
- { label: "Approved", path: "approved" }
- { label: "Rejected", path: "rejected" }

x and at least one series entry are required. A row missing one series’ field renders a gap in that line rather than crashing or misaligning the others. If a series needs real aggregation (not already computed by the underlying query), that belongs in the query — line_chart has no Python-side reduction path of its own, the same principle as bar_chart’s agg: shorthand.

Assets (internal, company-scoped JSON records) get their own tile source instead of being forced through connector configuration — a GET /assets/-equivalent, not an external system.

- id: server_baselines
type: table
source:
type: asset # replaces data_processor entirely for this tile
category: server # same category filter list_by_category uses
filter: "properties.tier = 'production' and properties.os_version = 'Ubuntu 22.04'"
columns:
- { label: "Name", path: "name" }
- { label: "OS Version", path: "properties.os_version" }

Visibility is scoped per viewer (owner / view_roles), the same rule GET /assets/ already enforces — not the unrestricted company-wide read list_by_category uses internally for trusted workflow execution. A dashboard is browsed by a person, not run as a trusted workflow step, so it never surfaces assets the viewer isn’t authorized to see in the Assets UI itself.

source: {type: asset} tiles have no row_path — the query result is already [{name, properties}], the exact row array shape, so there’s nothing for JSONata array-extraction to do. schema_id/schema_name filtering is not supported — only category, matching the one existing internal precedent.

Tile data_processor.params[] reuses the workflow DataSourceParameterMapping model, plus two dashboard-only sources:

data_processor:
source_name: "Vendor Spend SQL"
params:
- name: date_from
from_control: date_from # reads a Dashboard control's resolved value
- name: cursor
from_asset: sync-checkpoint
property: $.last_cursor
- name: api_version
value: "v3"
- name: member_email
from_viewer: email # NEW — the live viewer executing this tile: employee_id or email

Each param entry requires exactly one source: from_control, from_viewer, from_asset (+ optional property), or value.

from_field is not valid on a dashboard tile. It reads a workflow request’s form fields, and a dashboard has no form, so the param would never be sent. The dashboard validator rejects it; use from_control for a user-chosen value or from_viewer for the current user instead.

The reverse also holds: both from_control and from_viewer are validation errors on a workflow automatic step — workflows have no control_values/live-viewer context at runtime; the parser allows either structurally (the model is shared) but both the YAML validator and the execution layer reject them there.

from_viewer personalizes a data_processor tile per viewer — e.g. “rows where I am a member” — the same effect a source: {type: asset} tile already gets for free via its own per-viewer owner_id/view_roles scoping (see “Native Asset Source” below). It resolves to the live user for a normal execute_tile call, and to each employee recipient in turn for a scheduled subscription run. A recipient added by bare email address (no employee record) never receives a from_viewer tile.

data_processor.join[] reuses the identical batch-fetch-then-lookup mechanism workflow automatic steps already have — one batched fetch across every distinct FK value, an on-keyed lookup dict, pick/as copied onto each matching row, non-matches left unenriched rather than erroring. See the ApprovalML Syntax Reference — Inline Join for the full mechanics.

Dashboards extend it with source_type, to join against the asset registry instead of an external connector:

- id: billing_with_server_tier
type: table
data_processor:
source_name: "Billing Export SQL"
join:
- field: server_name
source_type: asset # NEW — data_source (default) | asset
category: server # optional, narrows the candidate set
on: name # assets are matched by name
pick: { tier: properties.tier, owner: properties.owner_name }
row_path: "data"
Field Required Default Description
field Yes — FK field name on each primary row — a single id or a list of ids (e.g. an order’s order_line: [123, 456]); every id in the list is looked up.
source_type No data_source data_source | asset.
source_id / source_name One required, unless source_type: asset — Which second data source to fetch. Must be absent when source_type: asset.
category No — Narrows the asset candidate set — only meaningful when source_type: asset.
on No "id" Key field to match against on the joined records (assets: typically "name").
pick No "name" Single field, or {output_field: source_field, ...} for several at once.
as Required if pick is a string — Output field name.
param No "ids" Param name the collected IDs are sent as (data-source joins only).
separator No ", " Joiner when a row has multiple matches.
as_array No false Return a list instead of a joined string.

field/on/pick are shallow dotted paths, not JSONata: properties.tier walks into a nested object, and a purely-numeric segment indexes into a list — product_id.1 reads the display-name half of a connector’s [id, name] pair for a relational field (e.g. Odoo many2one), product_id.0 reads the id half. Picking a field like that without an index (pick: product_id) returns the raw pair stringified as one value (e.g. "[2778, 'Widget']") rather than the name — index into it instead. This dotted-path/list-index behavior is dashboard-specific; the workflow join linked above only reads flat top-level field names.

Whole-record pick. pick: "$" (or {output_field: "$"} inside a dict pick) attaches each matched record as-is instead of extracting one field — use it to pull in every joined field at once rather than listing them one by one:

join:
- field: order_line
source_id: src_...
on: id
pick: { lines: "$" } # every field of each matched order line
as_array: true

Type preservation. With as_array: true, picked values keep their original type from the source record — a numeric field stays a number so $sum(rows.lines.price_total)-style arithmetic works downstream without casting. Without as_array (the default), multiple matches are joined into a single string via separator, which necessarily stringifies each value first — that’s the only case where a picked value becomes text.

Available on every tile type — not just table. A JSONata predicate evaluated against the whole rows array, applied once, after row extraction and join enrichment, before any tile-type-specific processing:

filter: "properties.tier = 'production' and properties.os_version = 'Ubuntu 22.04'"
rows[properties.tier='production'] -> matches
rows[properties.tier='production' and properties.os_version='Ubuntu 22.04'] -> and: native operator, works
rows[not properties.tier='staging'] -> PARSE ERROR — not a keyword
rows[$not(properties.tier='staging')] -> correct: $not() is a function

and/or compose as native keyword operators; negation is not a not keyword — it’s the $not(...) function. The natural-looking not syntax parses successfully at YAML-author time and only fails later, so the YAML validator try-parses it up front.

Sort with ^(<field) (ascending) / ^(>field) (descending). To then take the first N rows, the range operator (..) only parses as an array literal — it is not a valid index predicate directly against an expression:

rows ^(>amount) [0..4] -> PARSE ERROR — "Expected ], got .."
rows ^(>amount) [[0..4]] -> correct: top 5 rows by amount, descending

Wrap the range in its own brackets: the inner [0..4] builds the index array [0,1,2,3,4], and the outer [...] applies it against the sorted array. This differs from the standalone JSONata reference implementation (where single-bracket expr[0..4] slicing is valid) — this dashboard engine’s parser only accepts the range operator as a literal array constructor.

stat.settings.metric/compare_metric and bar_chart.y (when agg: is omitted) are evaluated as a single JSONata expression against the whole extracted row array — {"rows": [...]} — not per row:

$sum(rows.amount) -> 1600
$count(rows) -> 3
$average(rows.amount) -> 533.33...
$count(rows[status="error"]) -> 2 (filtered count)
(1 - $count(rows[status="error"]) / $count(rows)) * 100 -> 33.33 (ratio / health-score style)
$sum(rows.amount) + $sum(rows[status="error"].amount) -> 2300 (combined sum)
rows{region: $sum(amount)} -> {"US": 700, "EU": 900} (group-by)

JSONata’s own aggregate functions ($sum, $count, $average, $max, $min), arithmetic operators, predicate filtering, and object-grouping cover count/sum/ratio/combined-sum/group-by natively — no Python-side aggregation engine is needed for any of it. agg: sum|avg|count|min|max remains as optional shorthand for the common single-plain-field case; when present, the field is a plain path aggregated by the named function instead.

Combined with a join — since join enrichment runs before metric/y evaluation, an arithmetic expression can reference a field a join just attached:

data_processor:
source_name: "Vendor Spend SQL"
join:
- field: vendor_id
source_name: "Vendor Master"
on: id
pick: { vendor_tier: risk_tier } # attaches vendor_tier to each row
settings:
metric: "$sum(rows[vendor_tier='high_risk'].totals.amount)" # arithmetic over the join-enriched rows

Per-row vs. whole-set context, at a glance:

Context Fields
Per-row (a single row dict) table.columns[].path, scatter.x/y/size/point_label, bar_chart.x (the grouping key), line_chart.x/series[].path
Whole-set ({"rows": [...]}) stat.settings.metric/compare_metric, bar_chart.y when agg: is absent

Reuses the exact form.layout.sections shape workflow forms already use, just pointing grid/columns cells at tile ids instead of form field names:

layout:
sections:
- id: overview
title: "Overview"
columns:
- [total_spend]
- [top_vendors, spend_by_region]
- id: correlation
title: "Correlation"
columns:
- [error_vs_delay]

columns mode (independent vertical stacks) suits dashboards particularly well since tiles are commonly mixed-height — a short stat tile next to a tall table. Both grid and columns modes are checked for unknown tile-id references (a broader check than the workflow layout validator, which only ever checks grid).

Runs on the given cron schedule, executes every tile in the dashboard, and emails a static HTML digest (tables as HTML, stats as formatted KPI text, charts as inline SVG snapshots) to the subscription’s recipients, with a CSV attached per table tile.

Each recipient only receives the tiles they are allowed to see:

  • Being listed as a recipient counts as access to the dashboard, so a tile without its own view_roles goes to every recipient.
  • A tile with its own view_roles goes only to recipients whose company roles match it (plus company admins and the dashboard’s creator).
  • Tiles whose data depends on who is looking — a from_viewer param, or source: {type: asset} — are run separately for each employee recipient, so everyone gets their own data.
  • A recipient added by bare email address (no employee record) only receives tiles with no view_roles of their own and no per-viewer data.
  • A recipient left with no visible tiles is not sent an email.
subscriptions:
- name: "Monday Morning Digest"
schedule: "0 8 * * 1" # same cron syntax as workflow triggers
recipients:
- role: "finance_manager" # same recipient shape as notification steps (role | email)
control_values:
date_from: "2026-01-01" # fixed control values for the scheduled run
date_to: "2026-01-31"

recipients requires at least one entry, each with role or email.

Mounted at /dashboards (plural) — coexists with the pre-existing singular /dashboard home-page endpoints with no route collision.

Method Path Description
GET /dashboards/ List dashboards visible to the current employee.
GET /dashboards/{uuid} Fetch one dashboard, including its parsed definition.
POST /dashboards/ Create — body: { "yaml_content": "<raw YAML>" }.
PUT /dashboards/{uuid} Update — same body shape.
DELETE /dashboards/{uuid} Soft-delete.
POST /dashboards/{uuid}/tiles/{tile_id}/execute Execute one tile — body: { "control_values": {...} }. Add ?format=csv to export a table/bar_chart/scatter/line_chart tile’s rows as CSV (not available for stat).

POST/PUT accept only raw yaml_content — name/description/view_roles are read back out of the parsed document after validation, so the stored row can never drift from what the YAML actually says.

Access control:

  • View: company admin, OR the dashboard’s creator, OR an employee whose company_roles intersect view_roles.
  • Write (POST/PUT/DELETE): company admin, OR the dashboard’s creator only — no org-path-ancestor escalation.
  • Per-tile view: the same rule, narrowed to a tile’s own view_roles when it sets one (falling back to the dashboard’s view_roles otherwise). GET /dashboards/{uuid} omits any tile the viewer can’t see from definition.tiles entirely; POST .../tiles/{tile_id}/execute 403s if called for one directly.
kind: dashboard
name: "Vendor Spend Overview"
description: "Quarterly vendor spend by region"
view_roles: ["finance_manager", "admin"]
controls:
- name: date_from
label: "From"
type: date
- name: date_to
label: "To"
type: date
- name: timeframe
label: "Timeframe"
type: date_range_preset
options:
- { label: "Last 7 Days", value: "last_7_days" }
- { label: "Last 30 Days", value: "last_30_days" }
- { label: "This Quarter", value: "this_quarter" }
tiles:
- id: top_vendors
type: table
label: "Top Vendors This Quarter"
data_processor:
source_name: "Vendor Spend SQL"
params:
- name: date_from
from_control: date_from
- name: date_to
from_control: date_to
join:
- field: vendor_id
source_name: "Vendor Master"
on: id
pick:
vendor_name: name
vendor_region: region_code
row_path: "data"
columns:
- { label: "Vendor", path: "vendor_name" }
- { label: "Region", path: "vendor_region" }
- { label: "Total Spend", path: "totals.amount" }
- id: net_spend_by_region
type: bar_chart
label: "Net Spend by Region (after refunds)"
data_processor:
source_name: "Vendor Spend SQL"
row_path: "data"
x: "vendor.region.code"
y: "$sum(rows.totals.amount) - $sum(rows.totals.refund_amount)"
- id: total_spend
type: stat
label: "Total Spend"
template: kpi_badge
data_processor:
source_name: "Vendor Spend SQL"
row_path: "data"
settings:
layout: badge
format: currency
metric: "totals.amount"
agg: sum
compare_metric: "totals.prior_period_amount"
compare_label: "vs last month"
- id: server_baselines
type: table
label: "Production Server Baselines"
source:
type: asset
category: server
filter: "properties.tier = 'production'"
columns:
- { label: "Name", path: "name" }
- { label: "OS Version", path: "properties.os_version" }
- id: error_vs_delay
type: scatter
label: "Accuracy Errors vs. SLA Bottlenecks"
data_processor:
source_name: "Audit Findings SQL"
row_path: "data"
x: "error_rate"
y: "sla_delay_hours"
size: "dollar_risk"
point_label: "vendor_name"
quadrants:
x_threshold: 5.0
y_threshold: 24
labels:
high_high: "Critical Process Failure"
low_low: "Healthy"
- id: approved_vs_rejected_trend
type: line_chart
label: "Approved vs. Rejected, by Day"
data_processor:
source_name: "Aptiwise Internal DB"
row_path: "data"
x: "day"
series:
- { label: "Approved", path: "approved" }
- { label: "Rejected", path: "rejected" }
layout:
sections:
- id: overview
title: "Overview"
columns:
- [total_spend]
- [top_vendors, net_spend_by_region]
- id: assets
title: "Assets"
columns:
- [server_baselines]
- id: correlation
title: "Correlation"
columns:
- [error_vs_delay]
- id: trends
title: "Trends"
columns:
- [approved_vs_rejected_trend]
subscriptions:
- name: "Monday Morning Digest"
schedule: "0 8 * * 1"
recipients:
- role: "finance_manager"
control_values:
date_from: "2026-01-01"
date_to: "2026-01-31"

A larger worked example covering every tile/control combination, both join variants, and filter: lives at docs/examples/dashboards/vendor-spend-dashboard.yaml in the repository.