Reports
Skill body version 0.5.2. Workflows here are named — Schedule, Explain, Update, Run, Explore. Older bodies lettered them A–E, and other Costory surfaces used a different letter order. If you are holding a lettered routing table for reports, it is stale: route by the names in this body and ignore the letters.
A report has a shared reportContext input (global theme) and widgets that inherit it by default. It delivers those widgets (chart snapshot, PDF, top/flop, text, or DIGEST cost-change tree) to one or more destinations (Slack, Teams, email). Same mental model as dashboards: shared context + per-widget overrides. create_report, update_report, and preview_report_widget all take the same report-level reportContext.
Temporary compatibility: the deprecated top-level
contextalias is accepted during migration, but always generatereportContext. Never send both.
Load this skill first for any report creation, DIGEST preview, substantial update, or delivery conversation.
Must-follow rules
Everything below this section is detail. These eight are the contract:
- Ask before build (Schedule / delivery). Do not pick widgets, call
preview_report_widget, or callcreate_reportuntil the design is confirmed. Never silently default to a graph or an AI summary on Schedule. - Explain exception — preview-first DIGEST. For one-shot "what changed / explain last month" (Explain workflow or
explain-period-changerecipe), defaulting to DIGEST chat preview is required. Do not wait for a full design questionnaire; do not substitutequery+comparefor the first answer. - Context-first. Once answers are in, define the full
reportContextbefore listing widgets. Widgets carry only overrides — never duplicate what already lives inreportContext. - Destinations last. Do not call
list_available_destinationsuntil the user has named the channel type (Slack / Teams / email). Then list only that type and propose matches by name — never paste a whole channel list into the conversation. - AI features are opt-in. DIGEST-only. Set
display: "summary"for the executive narrative and/orenableAiInvestigation: truefor per-node deep analysis — both default off (tree-only) and are slower. - Confirm delivery. Both
NOWandSCHEDULEDneed explicit confirmation beforecreate_report.UNSCHEDULEDdoes not. datePreset, never frozen dates, onSCHEDULEDreports.- Cost data is ~2 days late. Prefer a Tuesday weekly send (
weekday: 2) when the period isLAST_WEEK(a completed calendar week). If the report must land earlier (e.g. Monday), useTRAILING_7_DAYSinstead ofLAST_WEEKso the window is not a half-empty calendar week.
Routing
| User intent (examples) | Workflow |
|---|---|
| "Generate a scheduled report / message for Slack, Teams, or email" | Schedule |
| "I want to share FinOps information with my team" (recurring or channel delivery) | Schedule |
| "I need to explain last month's cost" / "what changed last month and why" | Explain (NOW + DIGEST) |
| Update an existing report | Update |
| Run / retry / transfer / archive | Run |
| Explore a delivered DIGEST execution | Explore |
If intent is ambiguous between Schedule and Explain, ask: recurring delivery to a channel, or one-shot explanation of last month's costs?
Tool order
get_skillwithskillId: "reports"— this guideget_context— org context, currency, popular groupByslist_teams— only when team scoping may apply; each scope'sidis thescopeIdvaluesuggest_groupby— when a DIGEST hierarchy is open-ended; needs the planned period + the report's scope filterlist_available_destinations— only after the channel type is known, and only for a delivered reportpreview_report_widget→create_report/update_report— only after the design brief is confirmed
Destination discovery is a late step, not a warm-up. An org can have dozens of Slack channels; enumerating them before the user has said "Slack" (or "Teams", or "email") buries the design conversation in noise. Once the type is known, list only that type — and if the filtered list is still long, ask for a channel name or keyword and match against it rather than echoing every entry.
Workflow: Schedule — create a scheduled report
Triggers: scheduled message for Slack / Teams / email; share FinOps information with a team on a cadence.
Goal: accompany the user to design a recurring report they will actually use, then create a SCHEDULED report. Do not assume DIGEST or jump to create.
Tone: collaborative design partner. Explain options in plain language, offer concrete starters, confirm a short design brief before any preview or create. The user should feel guided, not interrogated with a long form.
1 — Open the design conversation
Acknowledge the goal (recurring FinOps delivery), then ask a few open questions in one short turn:
- What should this deliver? — what should recipients learn or do when it lands?
- Who / where? — Slack, Teams, or email (the type only; concrete destinations come at build time)
- How often? — weekly or monthly (rough is fine)
If they are unsure what to put in the report, do not wait passively — go straight to step 2 and offer starter ideas.
2 — Offer starter reports (proactively)
Present 3–5 options as a friendly menu (value prop first, then widgets). Invite pick / mix / customize:
| Starter | Plain-language pitch | Widgets | Typical schedule / presets |
|---|---|---|---|
| Monthly cost digest | "What changed last month?" — ranked movers as a tree; optional AI narrative for execs | DIGEST (± optional AI summary) | MONTHLY · LAST_MONTH |
| Weekly team pulse | "What blew up or dropped for each team last week?" | TOP_FLOP ± GRAPH_SNAPSHOT | WEEKLY · Tuesday + LAST_WEEK (or Monday + TRAILING_7_DAYS) / TRAILING_14_WEEKS |
| Migration / spend tracker | "Are we on track?" — trend over weeks + last-week movers | GRAPH_SNAPSHOT + TOP_FLOP | WEEKLY · Tuesday · GRAPH TRAILING_14_WEEKS, TOP_FLOP LAST_WEEK (or TRAILING_7_DAYS if not Tuesday) |
| Dashboard pack | "Send our existing FinOps dashboard on a cadence" | DASHBOARD_PDF | WEEKLY + weekday · period lives on the dashboard |
| Invoice / finance close | "Close the books on what moved last invoice month" | DIGEST (± DASHBOARD_PDF; optional AI summary) | MONTHLY · LAST_INVOICE_MONTH or LAST_MONTH |
Example framing: "Here are a few patterns that work well for teams. Which fits, or should we combine two?"
For a concrete, business-specific design (marketplace spend, namespace cost, provider credits, untagged coverage, env costs, …) load the recipes skill and Read the matching card — it comes back as a confirmed brief you build with this workflow.
3 — Deepen the design (after they pick a direction)
Fill only what is still missing — keep it conversational:
- Scope — whole org,
scopeIdfromlist_teams, and/orconditionsCel - Split / hierarchy — if DIGEST is in the mix: confirm root + deeper levels, or run
suggest_groupbyand propose a tree in plain language - Optional AI (DIGEST only) — ask explicitly: executive narrative (
display: "summary") and/or per-node investigation (enableAiInvestigation: true); both slower; default tree-only (display: "tree", investigation off) - Cadence details — WEEKLY needs
weekday(0 = Sunday … 6 = Saturday); setfirstRunAt(ISO-8601 UTC); use presets that roll forward (LAST_WEEK,TRAILING_7_DAYS,LAST_MONTH, …). Refuse frozenfrom/toon SCHEDULED reports. Data lag: billing/cost data is typically ~2 days behind. For a calendar-week pulse, defaultweekday: 2(Tuesday) withLAST_WEEK. If they insist on Monday (or another early weekday), switch the period toTRAILING_7_DAYS— do not pair Monday +LAST_WEEK(that full week still has incomplete late days). - Currency / metric — usually
cost+ org currency fromget_context
The channel type is already known from step 1; the concrete destination is resolved at build time, not here.
Then restate a one-paragraph design brief (audience, cadence, widgets, scope, AI narrative / investigation yes/no) and get confirmation before building.
4 — Build
get_context, pluslist_teamsonly if team scoping applies- Steps 1–3 above → design brief confirmed
- If the DIGEST hierarchy was open-ended →
suggest_groupbywith the planned period + scope filter → propose root +additionalGroupBy→ confirm - Draft
reportContextfirst — shareddatePreset/ metric / currency /groupBy/ scope - If DIGEST is in the mix →
preview_report_widget(defaults 100 / 5% / 20; setdisplay: "summary"/enableAiInvestigationonly if opted in) → tune → re-preview - Now resolve delivery:
list_available_destinationsfor the chosen channel type → propose matches by name → confirm the specific destination. Missing Slack/Teams integration → https://app.costory.io/integration - Confirm
schedule.mode: "SCHEDULED"(period, weekday,firstRunAt) →create_reportwith the samereportContext+ widgets you previewed
Workflow: Explain — last month (NOW + DIGEST)
Triggers: explain last month's cost; what changed last month and why; one-shot narrative for stakeholders. Prefer recipe explain-period-change when that card was loaded.
Goal: a chat DIGEST preview (then optional NOW) whose primary widget is DIGEST, with hierarchy from suggest_groupby. The core deliverable is the change tree from preview_report_widget; do not rebuild it with query.
1 — Defaults vs questions
Chat-only (default when they did not name a channel): assume whole org, LAST_MONTH (or LAST_INVOICE_MONTH if they said invoice), tree-only AI off. Ask only if scope/period is ambiguous.
Before NOW delivery (they asked to send): confirm scope, hierarchy, AI (display: "summary" / enableAiInvestigation), and channel type. GRAPH_SNAPSHOT only if they also want a trend image.
Do not skip to a graph-only report for this trigger — the core ask is explanation → DIGEST tree. Do not call query for the first answer.
2 — Discover the tree with suggest_groupby (same turn as preview)
get_context(+list_teamsonly if team scoping is needed)- Resolve period: prefer
reportContext.datePreset: "LAST_MONTH"(orLAST_INVOICE_MONTH) - Call
suggest_groupbywithfrom/tomatching that month and the samefilterCelas the planned scope - Pick a tree: root = top hit →
reportContext.groupBy; deeper levels = next useful hits →additionalGroupBy(typically 1–2 levels). If suggestions are empty/weak →popularGroupBys, elsecos_provider→cos_service_name - Chat-only: state the path in one sentence and go straight to preview (no confirmation wait). NOW: confirm the path before create
3 — Preview DIGEST (primary data tool)
- Draft
reportContext:datePreset: "LAST_MONTH", chosengroupBy,metricId,currency, optional scope preview_report_widgetwith{ reportContext, widget }(singularwidget, neverwidgets) — minimal DIGEST (additionalGroupBy, thresholds 100 / 5% / 20,aggBy: "Month";display: "summary"/enableAiInvestigation: trueonly if opted in)- Present only preview fields — required shape:
- Headline from
resolvedPeriod+totals topIncreases/topDecreases(path + Δ)- Tree outline from
rootNodes(label, Δ, childCount) - Footer: thresholds + optional
explorerUrl/comparisonPeriodSummary summaryMarkdownonly whendisplay: "summary"
- Headline from
- Stop. Offer drill-down / AI re-preview / NOW. Tune from
recommendations→ re-preview — still withoutqueryunless the user names a node to dig into
4 — Deliver
- Stop here if they only wanted an explanation in chat — no
create_reportneeded - To send: channel type known →
list_available_destinationsfor that type → confirm the channel → confirmschedule.mode: "NOW" create_reportwith the samereportContext+ that DIGEST object insidewidgets: […](plural array; GRAPH_SNAPSHOT only if requested)
Workflow: Update
get (report id) → preview if DIGEST content changes → update_report.
- Patch
reportContextalone to change the report-wide period / groupBy / filter / currency without rewriting widgets. - Pass
widgetsonly when replacing the widget list (wholesale replace). The field is always thewidgetsarray — a singularwidgetkey is silently ignored. - New widgets follow the same inheritance rules: omit fields that match
reportContext.
Workflow: Run — run, retry, transfer, archive
Retry failed executions only — never run_report_now to fix one destination. Do not poll executions in an unbounded loop.
Workflow: Explore — delivered DIGEST
get_report_execution → get_report_execution_widget with view: "tree" for the full formatted tree.
Payload cookbook
Read this half when drafting JSON — the conversation rules above still apply.
Report reportContext 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 — for DIGEST this is the root hierarchy axis | Cost widgets omit groupBy to inherit; DIGEST deeper levels go in additionalGroupBy |
datePreset or startDate/endDate |
Report period — prefer datePreset for scheduled reports |
Widgets omit from/to/datePreset to inherit |
conditionsCel |
Report-wide CEL filter (scope) | Merged into every cost widget at runtime |
scopeId |
Optional saved team scope | Report-wide unless a widget overrides |
Each query still needs "type": "cost" — the query kind is not inherited.
Report ownership (teamId, visibility on create_report) is separate from scopeId / conditionsCel (query filter only). Ownership controls who sees and edits the report; scope controls which costs the widgets query.
Inheritance resolution
At preview, create, and each scheduled run:
- Start from
reportContext(period,metricId,currency,groupBy,conditionsCel,scopeId). - Apply widget-level overrides (
datePreset/from/to,scopeId, DIGEST thresholds,aggBy, title, …). - Apply query-level overrides (
groupBy,metricId,currency,filterCel,chartType,alias). - Effective cost filter = report
conditionsCelAND widget/queryfilterCel(an empty side is a no-op).
Consequences worth knowing:
- Similarity rule: when widgets share a period, split, metric, currency, or scope, put it in
reportContextfirst. Widget fields are exceptions only. - Per-widget periods are normal — e.g. GRAPH =
TRAILING_14_WEEKS, TOP_FLOP =LAST_WEEK(Tuesday send) orTRAILING_7_DAYS(earlier weekday). Keep shared fields onreportContext; override onlydatePreset(and presentation) on the widget. - Never combine
datePresetwith explicit dates on the same layer. conditionsCelis report-wide — do not repeat it inside every widgetfilterCel.- TEXT and DASHBOARD_PDF do not inherit query fields. PDF period lives on the referenced dashboard; TEXT is static markdown in
contentMarkdown(not dashboardtextContent). - Use
startDate/endDate(or widgetfrom/to) only for truly custom one-off ranges — never onSCHEDULEDreports.
Widget types
| Type | What it delivers | Best for |
|---|---|---|
DIGEST |
Cost-change tree vs previous period; optional display: "summary" narrative and/or enableAiInvestigation |
Explain what moved (monthly / weekly) |
GRAPH_SNAPSHOT |
Chart image from a cost/usage/metric query | Trends, migration tracking, composition over time |
TOP_FLOP |
Ranked increases / decreases for a period | "What blew up / what dropped last week?" |
DASHBOARD_PDF |
PDF of an existing dashboard (dashboardId) |
Weekly/monthly pack of a known dashboard |
TEXT |
Static markdown via contentMarkdown |
Context, how-to-read notes, links (not live AI) |
A report can combine several widgets. Prefer variety of questions, not duplicate charts.
DIGEST AI options (display + enableAiInvestigation)
Two independent DIGEST fields control AI (both opt-in; both slower):
| Field | Values / default | What it does |
|---|---|---|
display |
"tree" (default) | "table" | "summary" |
Presentation. "summary" = LLM executive narrative (summaryMarkdown). "tree" / "table" = structured view only (faster). |
enableAiInvestigation |
false (default) | true |
Per-node deep analysis on deepest-leaf movers (async; delivery waits). Independent of display. |
Analysis presets (map intent → fields; do not invent other knobs):
| Intent | Fields |
|---|---|
| Direct tree (default) | display: "tree", enableAiInvestigation: false (or omit both) |
| Executive summary | display: "summary", enableAiInvestigation: false |
| Deep investigation | display: "summary", enableAiInvestigation: true, topLargestAbsoluteChange: 20 |
- Offer narrative when they want a written overview of what changed and why (exec / stakeholder read). Set
display: "summary". - Offer investigation only when they want per-node AI explanations under the tree — warn it is noticeably slower. Set
enableAiInvestigation: true. Preferdisplay: "summary"with it so the narrative can ground on those findings; do not ask a second tree-vs-summary question once deep investigation is chosen. - Say the trade-off out loud before enabling either. Ask plainly: "Tree-only (faster), an AI executive summary, or deep per-node investigation (slowest)?"
- Default to tree-only unless they opt in. Never invent a separate AI widget or fake a summary via TEXT.
- Only DIGEST produces
summaryMarkdown— never claim one from GRAPH_SNAPSHOT, TOP_FLOP, TEXT, or DASHBOARD_PDF.
Pairings: tree is enough → DIGEST alone (defaults). Narrative + tree → DIGEST with display: "summary". Deep dig → DIGEST with display: "summary" + enableAiInvestigation: true. Trend or movers only → GRAPH_SNAPSHOT / TOP_FLOP. Static intro → TEXT.
TEXT widgets
Free-form markdown block. Shape is { type: "TEXT", contentMarkdown } — optional title / description.
- Field name is
contentMarkdown(1–10,000 characters after trim). Do not usetextContent— that is the dashboards text-widget field (type: "text"). - No queries, period, or inheritance from
reportContext. - Not a substitute for the DIGEST AI summary (
summaryMarkdown).
{
"type": "TEXT",
"title": "How to read this report",
"contentMarkdown": "## Weekly pulse\n\nFocus on the TOP_FLOP movers below, then open the graph for trend context."
}
DIGEST hierarchy
DIGEST builds a tree. Levels map to fields:
| Level | Field | Notes |
|---|---|---|
| Root (top of tree) | reportContext.groupBy |
Preferred; use queries[0].groupBy only when one DIGEST must differ |
| Deeper levels (in order) | additionalGroupBy |
Ordered children only — never put the root here |
| User says | reportContext.groupBy (root) |
additionalGroupBy |
|---|---|---|
| provider → service | cos_provider |
["cos_service_name"] |
| env → project → service | environment |
["cos_sub_account_id", "cos_service_name"] |
| team → service | team |
["cos_service_name"] |
- Confirm the resolved path with the user when they describe a hierarchy in plain language (e.g. "production → Costory → AmazonEC2").
- Discover CEL field names via
searchwithtype: ["dimensions"], or fromget_contextpopularGroupBys. - For an open-ended hierarchy ("good split for last month"), call
suggest_groupbywith the planned period + the samefilterCel/ scope as the report — top hit as root, next hits asadditionalGroupBycandidates. Prefer dimensions that also appear in popularGroupBys when ties exist. - Never invent a deep tree without confirming it.
Query config (DIGEST, GRAPH_SNAPSHOT, TOP_FLOP)
Same query shape as query, minus anything that matches reportContext:
queries— for DIGEST: exactly one{ type: "cost", name: "a", filterCel? }(omitgroupBy/metricId/currencywhen they match reportContext)- Period — omit on widgets when
reportContext.datePreset(or start/end) is set; widget-leveldatePreset/from/toare overrides only. Never hand-compute dates for recurring schedules. For DIGEST the server also resolves the comparison period and echoesresolvedPeriod. aggBy- Widget
scopeId?only when it must differ fromreportContext.scopeId
Preview defaults (DIGEST)
| Field | Default |
|---|---|
minAbsoluteDiff |
100 |
minRelativeDiff |
5 (percent) |
topLargestAbsoluteChange |
20 (allowed: 5, 10, 15, or 20) |
display |
"tree" (set "summary" only when opted in) |
enableAiInvestigation |
false (set true only when opted in) |
Always show resolvedPeriod (when present), comparisonPeriodSummary, totals, counts, topIncreases / topDecreases, and rootNodes. Show summaryMarkdown only when display: "summary" (and warn that that preview may take longer).
Tune from recommendations: thresholds, topLargestAbsoluteChange (only 5, 10, 15, or 20), grouping. Repeat preview_report_widget until satisfied.
Examples
preview_report_widget — monthly DIGEST (tree-only defaults):
{
"reportContext": {
"datePreset": "LAST_MONTH",
"groupBy": "environment",
"metricId": "cost",
"currency": "USD"
},
"widget": {
"type": "DIGEST",
"queries": [{ "type": "cost", "name": "a", "alias": "Cost by environment" }],
"aggBy": "Month",
"additionalGroupBy": ["cos_sub_account_id", "cos_service_name"],
"minAbsoluteDiff": 100,
"minRelativeDiff": 5,
"topLargestAbsoluteChange": 20
}
}
Same DIGEST widget with executive AI summary (add on the widget when opted in):
{
"type": "DIGEST",
"queries": [{ "type": "cost", "name": "a", "alias": "Cost by environment" }],
"aggBy": "Month",
"additionalGroupBy": ["cos_sub_account_id", "cos_service_name"],
"minAbsoluteDiff": 100,
"minRelativeDiff": 5,
"topLargestAbsoluteChange": 20,
"display": "summary",
"enableAiInvestigation": false
}
create_report / update_report — same DIGEST goes in widgets (plural array).
Migration tracker (graph + top/flop — not DIGEST). Pair with schedule.weekday: 2 (Tuesday) when using LAST_WEEK; if the send day is Monday, set reportContext.datePreset to TRAILING_7_DAYS instead:
{
"reportContext": {
"metricId": "cost",
"currency": "USD",
"groupBy": "team",
"datePreset": "LAST_WEEK"
},
"widgets": [
{
"type": "GRAPH_SNAPSHOT",
"title": "Cost by team — trailing weeks",
"queries": [{
"type": "cost",
"name": "a",
"alias": "Cost by team",
"chartType": "LINE"
}],
"datePreset": "TRAILING_14_WEEKS",
"aggBy": "Week"
},
{
"type": "TOP_FLOP",
"title": "Last week movers by team",
"queries": [{
"type": "cost",
"name": "a",
"alias": "Cost by team"
}],
"aggBy": "Period",
"topN": 5,
"flopN": 5
}
]
}
Weekly dashboard PDF widget:
{
"type": "DASHBOARD_PDF",
"dashboardId": "<id from search>"
}
TEXT intro (report field is contentMarkdown, not dashboard textContent):
{
"type": "TEXT",
"title": "How to read this report",
"contentMarkdown": "## Weekly pulse\n\nFocus on the TOP_FLOP movers below."
}
Full create_report (scheduled):
{
"visibility": "PRIVATE",
"schedule": {
"mode": "SCHEDULED",
"period": "MONTHLY",
"firstRunAt": "2026-08-01T10:00:00.000Z"
},
"reportContext": {
"datePreset": "LAST_MONTH",
"groupBy": "environment",
"metricId": "cost",
"currency": "USD"
},
"widgets": [{ "type": "DIGEST", "...": "minimal widget overrides only" }],
"destinations": [{ "destinationType": "SLACK", "channelId": "C…" }]
}
Payload anti-patterns
The conversation rules live in Must-follow rules above. These are the shape mistakes:
- Do not put the root hierarchy axis in
additionalGroupByinstead ofreportContext.groupBy - Do not repeat
metricId,currency,groupBy,from/to, ordatePreseton a widget when it already matchesreportContext - Do not put the global scope in each widget's
filterCelinstead ofreportContext.conditionsCel - Do not confuse ownership (
teamId/visibility) with query scope (scopeId/conditionsCel/filterCel) - Do not use different widget content for preview vs create — same
reportContext+ DIGEST fields — but the key differs: preview =widget, create/update =widgets[] - Do not pass
widgetstopreview_report_widgetor a singularwidgettocreate_report(Zod strict rejects both) - Do not send dashboard
textContenton a report TEXT widget — the field iscontentMarkdownwithtype: "TEXT" - Do not skip preview before create when DIGEST is in the mix
- Do not skip confirming the DIGEST tree path after
suggest_groupbywhen delivering NOW/SCHEDULED (chat-only Explain may preview in the same turn) - Do not substitute destinations silently
- Do not use
query+compareas a substitute for Explain’s DIGEST preview on the first answer - Do not rebuild DIGEST movers as explorer tables or a canvas before presenting preview fields
- Do not schedule a weekly
LAST_WEEKreport for Monday (weekday: 1) — cost data is ~2 days late; use Tuesday (weekday: 2) withLAST_WEEK, or keep Monday and switch toTRAILING_7_DAYS - Do not generate the deprecated
contextalias or send it together withreportContext
Related skills
dashboards— when the user wants an interactive dashboard instead of a delivered report (same context-first inheritance model; also usessuggest_groupby)query— after DIGEST preview, drill a user-named node (filterCel); not the primary tool for “what changed last month”recipes→explain-period-change— one-shot explain outcome card (preview-first DIGEST)virtual-dimensions— when a DIGEST hierarchy needs a custom axis that does not exist yet