Dashboards
A DashboardV2 has a shared dashboardContext input (global theme) and widgets that inherit it by default. Each widget is either a chart (Advanced Explorer queries + chartType) or a text block (type: "text", textContent).
Context-first rule: define the full dashboardContext before listing widgets. Widgets should contain only overrides — never duplicate what already lives in dashboardContext. If two or more widgets share the same period, groupBy, metric, currency, or filter scope, extract that shared value to dashboardContext first.
Temporary compatibility: the deprecated top-level
contextalias is accepted during migration, but always generatedashboardContext. Never send both.
When to Trigger
- Creating a new Costory dashboard
- Generating an interesting overview when the user has not specified every widget (use How to generate interesting dashboards below)
- Adding, replacing, or removing widgets on an existing dashboard
- Editing
dashboardContext(global filter, period, groupBy, metric, currency) without recreating - Copying a widget to another dashboard
- User asks for a cost overview "by service / by region" style dashboard
Dashboard dashboardContext fields
| Field | Role | Widget inheritance |
|---|---|---|
metricId |
Default cost column (e.g. "cost") |
Cost widgets omit metricId to inherit |
currency |
USD, EUR, GBP, CNY | Cost widgets omit currency to inherit |
groupBy |
Default split dimension — use when most widgets share the same axis | Cost widgets omit groupBy to inherit (or set null explicitly for totals) |
datePreset or startDate/endDate |
Dashboard period — required when chart widgets are present (text-only dashboards may omit). Prefer datePreset whenever an available preset matches the requested range |
Widgets omit from/to/datePreset to inherit |
conditionsCel |
Dashboard-wide CEL filter (scope) | AND-merged into every cost widget by default |
scopeId |
Optional saved scope | Inherited like other context fields |
Each query still needs "type": "cost" (query kind — not inherited). What you omit on cost widgets is metricId, currency, groupBy, and period when they match dashboardContext.
Inheritance semantics
- Period, metricId, currency, groupBy: omit on widgets when they match
dashboardContext. - Similarity rule: when widgets or dashboards are very similar, identify the common
datePreset/dates,groupBy,metricId,currency, andconditionsCelbefore writing widgets. Put common values indashboardContext; widget fields are for exceptions only. groupBy: omit the field to inheritdashboardContext.groupBy; setgroupBy: nullto explicitly disable grouping for that widget.- Period presets: if the user's range maps to an available
DatePreset(for exampleTRAILING_30_DAYS,TRAILING_90_DAYS,LAST_MONTH,YTD), usedashboardContext.datePreset. UsestartDate/endDateonly for truly custom ranges. conditionsCel: inherited by default. Every cost widget AND-merges dashboardconditionsCelwith its ownfilterCel.extendDashboardConditions: defaults totrue(inherit dashboard filter). Setfalseonly when the widget must ignore the dashboard filter entirely.- Widget
filterCel: widget-specific scope only — do not put the dashboard-wide filter here; put it indashboardContext.conditionsCel.
Widget types
Two top-level widget kinds in create_dashboard / update_dashboard:
| Kind | How to identify | Required fields |
|---|---|---|
| Chart | omit type, or "type": "chart" |
title, queries (each with type + name), usually aggBy |
| Text | "type": "text" |
title, textContent (markdown or plain text) |
Chart widget — queries[].chartType
Set chartType on each series (cost / metric / usage / formula / budget / externalMetric). Omit → LINE. Primary series for sizing = first non-formula query (else first query).
chartType |
Use when | Typical aggBy |
Notes |
|---|---|---|---|
BAR |
Categorical / stacked spend over time | Day / Week / Month |
Default for breakdowns ("by service", "by region") |
LINE |
Trends / continuous evolution | Day / Week / Month |
MCP default if chartType omitted |
AREA |
Filled trend (e.g. budget vs spend) | Day / Week / Month |
Budget queries often default to AREA in the product |
TABLE |
Tabular breakdown / many groups | any | Needs more grid space; also a valid compare.chartType |
WATERFALL |
Rare as a series chartType |
— | Prefer enabling comparison via widget compare (default comparison render is WATERFALL) |
Donut (composition for one period): there is no separate DONUT enum. Use chartType: "BAR" + widget aggBy: "Period" (and a groupBy). That is the product donut.
Time series vs period comparison
These are different modes — pick one per widget based on the question.
| Mode | What it answers | How to configure | Prefer when the user asks… |
|---|---|---|---|
| Time series | How cost (or a metric) evolves within one period | No compare. Series chartType: BAR / LINE / AREA. aggBy: Day / Week / Month |
"trend", "evolution", "over time", "daily/weekly/monthly spend", "seasonality", "when did it spike" |
| Period comparison | How this period differs from another period (delta / movers) | Widget compare (see below). Renders via compare.chartType |
"vs last month", "WoW / MoM", "what changed", "increase/decrease", "period-over-period", "top movers" |
Enabling compare:
| Shape | Meaning |
|---|---|
compare: {} or compare: { enabled: true } |
Turn comparison on. Omit from/to → server auto-derives the preceding period from the widget/dashboard primary period (same as the UI — preset-aware, e.g. LAST_MONTH → previous calendar month, not "N days earlier") |
compare: { from, to } |
Custom comparison range (both required together) |
compare: { …, chartType } |
How comparison renders: WATERFALL (default), TABLE, or KPI_BREAKDOWN. Note: KPI_BREAKDOWN / TABLE cannot be exported as PNG via get_dashboard_widget_image |
Prefer datePreset on the dashboard (or widget) + compare: {} for "vs previous period". Only pass compare.from/to for a custom other range.
{
"title": "MoM by service",
"queries": [{ "type": "cost", "name": "a", "chartType": "BAR", "groupBy": "cos_service_name" }],
"aggBy": "Period",
"compare": {}
}
Rules of thumb:
- Default to time series for overview dashboards and monitoring (spend over the last 30/90 days).
- Use comparison only when the insight is the change between two ranges, not the shape of a single range.
- Do not add
comparejust to show a BAR/LINE — that switches the widget into comparison mode. - Donut (
BAR+aggBy: "Period") is a single-period composition view, not a comparison and not a time series. - Series
chartType(queries[].chartType) and comparisoncompare.chartTypeare different enums —KPI_BREAKDOWNexists only oncompare.chartType.
Text widgets
Notes, headings, or commentary — no queries, period, or dashboard inheritance.
{
"type": "text",
"title": "Notes",
"textContent": "## AWS overview\nKey findings..."
}
Grid sizing (w / h / x / y)
Dashboard layout is a 12-column grid. Rows grow downward.
w: width in columns (1–12)h: height in rowsx/y: optional pin (0-based). Onupdate_dashboardop: "add", supply both together or the widget is auto-packed at the end. Use values fromgetwhen copying a layout.
| Widget / chart | Default w × h |
Fits per row (12-col) |
|---|---|---|
BAR / LINE / AREA / WATERFALL |
3 × 2 | 4 |
TABLE |
4 × 3 | 3 |
Text (type: "text") |
6 × 2 | 2 |
| Fallback (empty / unknown) | 4 × 3 | 3 |
When to override size (set w/h explicitly):
| Goal | Suggested w × h |
|---|---|
| Half-width chart (two side-by-side) | 6 × 3 or 6 × 4 |
| Full-width chart or table | 12 × 3 (chart) / 12 × 4 (table) |
| Compact donut next to a wide bar | donut 3 × 3, bar 9 × 3 |
| Section heading / short note | text 12 × 1 or 6 × 2 |
| Long markdown commentary | text 12 × 3+ |
Layout tips: omit w/h for a simple row of equal BAR/LINE charts (four 3×2). Prefer larger h for TABLE and multi-series AREA. Pin with x/y only when placing next to an existing widget or preserving a copied layout.
Supporting tools
| When | Tool |
|---|---|
| Org context, recent dashboards, popular groupBys | get_context |
| Discover CEL field names and values | search with type: ["dimensions"] |
| Validate filters / explore spend before building | query |
| Pick a meaningful default / secondary groupBy | suggest_groupby |
| Usage units for a specific scope (CPU hours, …) | suggest_usage_metrics |
| Read existing dashboard + widget layout | get |
| Create a new dashboard | create_dashboard |
| Add / replace / remove widgets | update_dashboard (Workflow B) |
Patch shared dashboardContext / global filter |
update_dashboard with dashboardContext (Workflow D) |
| Run saved widget data | get_dashboard_widget_data |
How to generate interesting dashboards
Use when the user wants a useful FinOps overview but has not listed every widget — e.g. "build a dashboard for AWS", "something interesting for the platform team", "Kubernetes cost overview".
Do not ship a wall of identical "by service" BARs. Discover axes with tools, then mix composition, trend, comparison, usage (when scoped), and text widgets that explain the story.
Full playbook (layout recipe, anti-patterns, extended examples): call get_skill with skillId: "plugins/costory/skills/dashboards/references/how-to-generate-interesting-dashboards.md"
Discovery (required)
get_context— popular groupBys, recent dashboards, currency habits- Lock scope as CEL → this becomes
dashboardContext.conditionsCel(provider, team, service, …) suggest_groupbywith periodfrom/tomatching the dashboard range + the samefilterCelas that scope- Top hit →
dashboardContext.groupBywhen ≥2 widgets share it - Next 1–2 hits → secondary widget
groupByoverrides
- Top hit →
- If scope is specific (not org-wide) →
suggest_usage_metricswith thatfilterCel→ optional usage / unit-economics widgets - Optionally
queryonce so intro/findings text can cite real drivers (never invent numbers) - Draft
dashboardContextfirst, then the widget palette below →create_dashboard
// suggest_groupby — same filterCel as dashboardContext.conditionsCel
{ "from": "2026-04-18", "to": "2026-07-17", "filterCel": "cos_provider in [\"AWS\"]" }
// suggest_usage_metrics — only with a narrow scope
{ "filterCel": "cos_service_name in [\"AmazonEKS\"]" }
Interesting widget palette
Build 5–8 widgets that answer different questions:
| Slot | Answers | Shape |
|---|---|---|
| Intro (text) | What is this? Scope, period, how to read | type: "text", usually w: 12, h: 2 |
| Composition | Where money goes now | Cost + groupBy, chartType: "BAR", aggBy: "Period" (donut) |
| Trend | Evolution | Same/inherited groupBy, BAR/LINE, aggBy: "Week" or "Month" |
| Secondary split | Next useful axis | Override groupBy from 2nd suggest_groupby hit |
| What changed | vs previous period | compare: {} (WATERFALL by default) |
| Usage | Volume behind cost | type: "usage" from suggest_usage_metrics (specific scope only) |
| Unit economics | Cost per unit | Cost a + usage/metric b + formula c: "a / b" |
| Detail table | Ranked breakdown | chartType: "TABLE", larger h |
| Findings (text) | So what? | type: "text" — top drivers, risks, next drill-downs (only facts from query) |
Text widgets turn charts into a story. Always include an intro; add findings when you inspected data. Put narrative in textContent, not stuffed into chart titles.
Layout sketch (12-col)
- Intro text —
12 × 2 - Composition
4 × 3+ Trend8 × 3 - Secondary split + table (or two bars) —
6 × 3each - Period comparison —
12 × 3 - Usage / unit economics if suggested —
6 × 3each - Findings text —
12 × 2
Example — interesting AWS overview
{
"name": "AWS interesting overview",
"dashboardContext": {
"conditionsCel": "cos_provider in [\"AWS\"]",
"groupBy": "cos_service_name",
"metricId": "cost",
"currency": "USD",
"datePreset": "TRAILING_90_DAYS"
},
"widgets": [
{
"type": "text",
"title": "How to read this dashboard",
"textContent": "## AWS cost overview\n\nTrailing 90 days. Default split: **service** (from suggest_groupby).\n\n1. **Composition** — share by service.\n2. **Weekly trend** — evolution of the same split.\n3. **MoM movers** — what changed vs the previous period.",
"w": 12,
"h": 2
},
{
"title": "Spend composition by service",
"queries": [{ "type": "cost", "name": "a", "chartType": "BAR" }],
"aggBy": "Period",
"w": 4,
"h": 3
},
{
"title": "Weekly trend by service",
"queries": [{ "type": "cost", "name": "a", "chartType": "BAR" }],
"aggBy": "Week",
"w": 8,
"h": 3
},
{
"title": "By region",
"queries": [{ "type": "cost", "name": "a", "chartType": "BAR", "groupBy": "cos_region" }],
"aggBy": "Month",
"w": 6,
"h": 3
},
{
"title": "Top services (table)",
"queries": [{ "type": "cost", "name": "a", "chartType": "TABLE" }],
"aggBy": "Period",
"w": 6,
"h": 3
},
{
"title": "MoM movers by service",
"queries": [{ "type": "cost", "name": "a", "chartType": "BAR" }],
"aggBy": "Period",
"compare": {},
"w": 12,
"h": 3
},
{
"type": "text",
"title": "What to watch",
"textContent": "## Next checks\n\n1. Drill into the largest service and re-run suggest_groupby under that filter.\n2. For a narrow slice (e.g. EKS), call suggest_usage_metrics and add usage / cost-per-unit widgets.",
"w": 12,
"h": 2
}
]
}
Workflow A — Create a new dashboard
get_skillwithskillId: "dashboards"(this guide) — you are hereget_context+ optionalsearch/queryto pick dimensions and validate CEL. If the widget list is open-ended, follow How to generate interesting dashboards (suggest_groupby, optionalsuggest_usage_metrics, text + mixed chart types)- Draft
dashboardContextfirst:metricId,currency, defaultgroupBywhen two or more widgets share the same split- Period: prefer
datePreset(e.g.TRAILING_90_DAYS) when possible; otherwise usestartDate+endDate - Global scope:
conditionsCelwhen all widgets share a filter
- List widgets as minimal overrides — each chart widget:
title,queries(type,name,chartType, optionalgroupBy/filterCel),aggBy. PickchartType+ size from Widget types / Grid sizing above. OmitmetricId,currency, period,groupBy, andconditionsCelwhen they matchdashboardContext. create_dashboard— include the returned URL in your reply
Example — AWS overview, by service and by region:
dashboardContext.metricId≠ query"type". Both can be"cost"but mean different things:
dashboardContext.metricId→ which cost column to query (inherited — widgets omitmetricId)queries[].type→ query kind ("cost","metric","formula"…) — required on every query, not inheritedWidgets below do not repeat
metricId,currency, period,groupBy, orconditionsCelfromdashboardContext. Only"type": "cost"stays on each query.
{
"name": "AWS Overview",
"dashboardContext": {
"conditionsCel": "cos_provider in [\"AWS\"]",
"groupBy": "cos_service_name",
"metricId": "cost",
"currency": "USD",
"datePreset": "TRAILING_90_DAYS"
},
"widgets": [
{
"title": "By Service",
"queries": [{ "type": "cost", "name": "a", "chartType": "BAR" }],
"aggBy": "Month"
},
{
"title": "By Region",
"queries": [{ "type": "cost", "name": "a", "chartType": "BAR", "groupBy": "cos_region" }],
"aggBy": "Month"
}
]
}
Workflow B — Extend an existing dashboard
search→ dashboard idget→ readcontextand each widget'squeryConfigupdate_dashboardwithop: "add"(orreplace/remove) — new widgets omit fields that match the dashboard context, especially the period and commongroupBy- Response includes
inheritedContext— use it to know what widgets can omit - Include the returned URL in your reply
Example — add a region breakdown:
{
"dashboardId": "clx9abc",
"operations": [{
"op": "add",
"widget": {
"title": "By Region",
"queries": [{ "type": "cost", "name": "a", "chartType": "BAR", "groupBy": "cos_region" }],
"aggBy": "Month"
}
}]
}
Workflow C — Copy a widget to another dashboard
geton the source dashboard → notex,y,w,hand the widget's effective query (resolvedqueryConfig)geton the target dashboard → read itscontextupdate_dashboardon the target withop: "add":- Pass
x,y,w,hto preserve layout - Rebuild queries using overrides only relative to the target dashboard context — do not copy inherited metricId/currency/period/filter fields that the target already provides
- Pass
Workflow D — Edit dashboard context
Change the shared context through the dashboardContext input (global filter, period, groupBy, metricId, currency, scopeId) without recreating the dashboard or rewriting widgets.
search→ dashboard id (optional if already known)get→ read currentcontext/conditionsCelupdate_dashboardwith a partialdashboardContextpatch — omit fields you want to keep- Response includes updated
inheritedContext;appliedis[]when no widget ops were passed - Include the returned URL in your reply
Patch semantics:
| Input | Effect |
|---|---|
| Field omitted | Keep existing value |
| Field provided | Overwrite |
conditionsCel: "" |
Clear the dashboard-wide filter |
conditionsCel: "<cel>" |
Replace the global filter |
Chart dashboards must keep a resolvable period after the merge — do not clear datePreset / dates if chart widgets remain.
Example — change the global filter only:
{
"dashboardId": "clx9abc",
"dashboardContext": {
"conditionsCel": "cos_provider in [\"AWS\"]"
}
}
Example — clear the global filter:
{
"dashboardId": "clx9abc",
"dashboardContext": {
"conditionsCel": ""
}
}
You can combine dashboardContext with operations in one call (new/replaced widgets inherit the merged context).
Per-widget overrides (complete catalog)
Chart widgets inherit dashboardContext for shared fields. Set a field on the widget (or query) only when it differs from dashboardContext. Text widgets ignore all of these (except title / description / layout).
Widget-level fields
| Field | Inherits from dashboardContext? |
Default if omitted | Override when… |
|---|---|---|---|
datePreset |
yes | dashboard period | This widget needs a different relative range (e.g. LAST_MONTH while dashboard is TRAILING_90_DAYS) |
from + to |
yes (together) | dashboard period | This widget needs a custom absolute range. Prefer datePreset when a preset fits. Do not mix with widget datePreset |
aggBy |
no (widget-only) | Month |
Granularity: Hour / Day / Week / Month / Period (Period = donut / single-bucket) |
compare |
no | off | Period comparison — prefer {} / { enabled: true } to auto-derive previous period from primary datePreset; or { from, to } for a custom range; optional chartType: WATERFALL | TABLE | KPI_BREAKDOWN |
extendDashboardConditions |
n/a (controls inheritance) | true |
Set false only when this widget must ignore dashboardContext.conditionsCel entirely |
scopeId |
yes | dashboard scopeId |
Widget should use a different saved team scope (list_teams) |
limit |
no | 100 |
Need more than 100 groups/rows (max 1000) |
title / description |
no | — | Always set title; description optional |
w / h / x / y |
no | auto size / auto pack | See Grid sizing |
Query-level fields (queries[])
| Field | Inherits from dashboardContext? |
Default if omitted | Override when… |
|---|---|---|---|
type + name |
no | — | Always required (type = query kind; name = single letter a/b/c) |
chartType |
no | LINE |
Choose BAR / LINE / AREA / TABLE / WATERFALL (see Widget types) |
groupBy |
yes | dashboardContext.groupBy |
Different split on this series. Set null for an ungrouped total (do not omit if you need to disable context groupBy) |
metricId |
yes (cost queries) | dashboardContext.metricId → "cost" |
Different cost column (effective_cost, etc.) |
currency |
yes (cost queries) | dashboardContext.currency → "USD" |
Different currency on this series |
filterCel |
no (widget-specific) | none | Extra scope for this series only — AND-merged with dashboard conditions when inheriting (see below) |
alias |
no | — | Human label for the series (max 50 characters); never put labels in name |
rollingAggregation |
no | off | Running totals / windowed agg (e.g. MTD budget burn: aggBy: Day + { aggregator: "SUM", window: { preset: "MONTH" } }) |
Conditions inheritance (extendDashboardConditions + filterCel)
Dashboard filter = dashboardContext.conditionsCel. Widget-specific filter = query filterCel.
extendDashboardConditions |
Effective CEL for cost/usage queries |
|---|---|
omit or true (default) |
(dashboardContext.conditionsCel) AND (filterCel) — if one side is empty, the other applies alone |
false |
filterCel only — dashboard conditionsCel is ignored for this widget |
Examples (dashboard conditionsCel: cos_provider in ["AWS"]):
// Inherit AWS + narrow to EC2 (effective: AWS AND EC2)
{ "title": "EC2", "extendDashboardConditions": true,
"queries": [{ "type": "cost", "name": "a", "chartType": "BAR",
"filterCel": "cos_service_name in [\"AmazonEC2\"]" }], "aggBy": "Month" }
// Ignore dashboard AWS filter — show all providers for this widget
{ "title": "All clouds", "extendDashboardConditions": false,
"queries": [{ "type": "cost", "name": "a", "chartType": "BAR" }], "aggBy": "Month" }
Do not repeat the dashboard-wide CEL inside every filterCel. Put shared scope in dashboardContext.conditionsCel; put exceptions in filterCel and/or extendDashboardConditions: false.
QUERY NAMING
name is a single letter (a, b, c) for formulas; put human labels in alias, never in name.
Safety Rules / Anti-patterns
- Do not repeat
metricId,currency,groupBy,from/to, ordatePreseton every widget when already indashboardContext - Do not use widget
from/tofor a common dashboard period when adashboardContext.datePresetis possible - Do not repeat the same
groupByacross similar widgets instead of settingdashboardContext.groupBy - Do not put the global scope (e.g.
cos_provider in ["AWS"]) in each widget'sfilterCelinstead ofdashboardContext.conditionsCel - Do not set
extendDashboardConditions: trueon every widget — omit it (default is inherit) - Do not call
create_dashboardwithout a period indashboardContext— validation rejects it (chart widgets need a dashboard period; text widgets do not) - Do not generate the deprecated
contextalias or send it together withdashboardContext - Do not skip
suggest_groupbyon open-ended "interesting dashboard" requests and always default tocos_service_name - Do not call
suggest_usage_metricswithout a specificfilterCel - Do not ship chart-only dashboards with no text intro / findings when generating an overview
- Do not invent findings numbers — only cite figures observed via
query
Related Skills / Next Steps
query— validate scope, inspect drivers before writing findings text, or explore usage / formulasreports— when the user wants a scheduled DIGEST or delivered report instead of (or in addition to) a live dashboardvirtual-dimensions— when the desiredgroupByaxis does not exist yet and must be built as a custom cost axis