Analyze budget vs forecast vs actual variances. Compares multi-scenario financial data for planning and performance review.
Analyze variances between your actual, plan, and forecast data. Scenario names are discovered from your data, never assumed — many orgs have no Budget scenario at all (plan data often lives in a separate planning-version field), so the plan side of the comparison is resolved at runtime (Step 1b).
Essential for FP&A reviews, planning adjustments, and performance tracking.
| Argument | Description | Default |
|---|---|---|
--year <YYYY> | REQUIRED Calendar year to analyze | — |
--scenarios <list> | Comma-separated scenario names, resolved against the discovered scenario domain (Step 1b) | Resolved at runtime — actual side + plan side + forecast, whichever exist |
--period <YYYY-MM> | Specific period to focus on | All year |
--output-xlsx <file> | Excel output path | tmp/Forecast_Variance_YYYY_TIMESTAMP.xlsx |
--output-pptx <file> | PowerPoint output path | tmp/Forecast_Summary_YYYY_TIMESTAMP.pptx |
When this skill is invoked from within Claude in Excel (the task pane add-in), switch to in-sheet enrichment mode instead of generating output files. This mode integrates the row-commentary approach from datarails-excel-multi-table-analysis and is the preferred mode when the user is working directly in a workbook.
How to detect the context: Excel context mode activates when the user has an open workbook and wants in-sheet enrichment — with or without an existing range. This applies in Claude for Excel (task pane), Claude Code with an open workbook, or any context where the user is working directly in a spreadsheet. Do not generate .xlsx or .pptx output files. Write all output directly into the active workbook using whichever Excel cell-write tool is available to you. Never use the Write tool to write spreadsheet content in this context. If no cell-write tool is available, fall back to file output mode and tell the user.
Before any data pull or schema discovery, run the agent.get_session probe through the
bridge. agent.get_session is not an MCP tool — you run it by executing Office.js via
the execute_office_js tool to write the request to the __dr_agent sheet and read the
response (see the Excel Context Contract in CLAUDE.md, §Transport). Do not call the
datarails-finance-os MCP connector for this.
execute_office_js / Office.js tool, or no bridge sheet) → not in Excel context. Fall back to file-output mode. Stop here.isLoggedIn): if isLoggedIn is false, tell the user to sign in to Datarails and stop — do not reach Step 0b. If true, proceed to Step 0b.isLoggedIn — it exposes isConnected instead): a successful probe means the session is active. Proceed to Step 0b. Do not treat isConnected: false as a login failure.Do NOT gate analysis on
isConnected. Refresh, DR-formula reads, and evaluate all work on an unconnected workbook.connect_fileis required only forcreate_dynamic_rangeanddrilldown_*, and only on COM (Flex sessions have noisConnectedfield). CheckisConnectedat the drill-down step (Step 6), not here, and only when the session payload contains it. If a drill needs it andisConnectedis false, ask: "This drill-down requires the workbook connected to Datarails. Connect now withconnect_file?" — wait for explicit yes (mutating command, never auto-call).
Step 0b — Data refresh (ask once per session, Excel context only): If agent.get_session succeeded, ask: "Should I refresh data from Datarails before pulling? (Recommended if you haven't refreshed today.)" If the user confirms, call refresh_ribbon (timeoutMs: 600000) and wait for terminal status. Skip if the user says no. On follow-up questions in the same session, do NOT re-ask or re-refresh — data is already fresh. In non-Excel context, skip entirely — refresh_ribbon is not available.
Every Datarails customer structures their Financials table differently. Never hardcode dimension field names. Before pulling any data, discover the financials table and its two key dimensions.
If you already discovered these this conversation, reuse them — skip to Step 2.
list_data_models first. Pick the financials table: the one whose name (or alias) matches /financial|cube|p&?l|ledger|gl/i, else the largest by row count. Note both its numeric id and its alias (alias may be empty). Prefer the alias path when an alias exists — friendlier field names, far fewer tokens.
Then read the schema — list_aliased_fields(<alias>) if the table has an alias, else get_fields_by_id(<id>) (capture each field's numeric id — the by-id tools address fields by id) — and identify:
Alias coverage is per field, not per table. A table having an alias does not mean its fields are aliased — real orgs often expose only a handful of aliased fields (e.g. ~5 of ~185 on a mapped financials table), and the load-bearing fields (
amount,scenario, account groups, dates) are frequently not among them. Treat the alias/by-id choice per field:get_fields_by_id(<id>)returns every field with its numericidand itsalias(empty if none). Address a field by alias (via the*_by_aliastools) when it has one, else by numericid(via the*_by_idtools). By-id always works — never abandon the query because the aliased set is thin.
| Dimension | What to look for | Common field names |
|---|---|---|
| Cost Center | The field that identifies the organizational unit / department incurring the cost | Cost_Center, DR_Cost_Center, Department, Dept_Name, CostCenter |
| Report Field | The field that identifies the P&L line item / account category as it appears in the customer's report | Report_Field, Report_Line, DR_ACC_L1, DR_ACC_L2, Account_Name, Line_Item |
If multiple candidates exist for either dimension, pick the most specific / granular one. If no candidate is identifiable by name (e.g., non-English field names), list the discovered fields and ask the user which to use. Document both discovered field names — use them in every aggregation for this analysis session. These two dimensions are mandatory; omitting either produces commentary that is not actionable.
If the schema genuinely has no identifiable cost center field, flag this in the Read Me block and fall back to the next-best grouping available (e.g., account hierarchy). Never silently drop the dimension.
Async fetch — aggregations and distinct values run as start → poll.
start_aggregation_by_id/_by_aliasandstart_distinct_values_by_id/_by_aliastake the same arguments as the retired blocking calls (dimensions/metrics/filters; table id + field id, or alias + field alias) and return immediately with{"status": "pending", "handle": {...}}. Echo thathandleback verbatim to the matchingget_aggregation_result_by_*/get_distinct_values_result_by_*tool: a{"status": "running", "retry_after_seconds": N}response means poll again with the same handle after ~N seconds (≈5s) — it is not an error, and large jobs may take several polls; when ready, the result arrives in the familiar shape (for distinct values, passlimitto the result tool). An expired/unknown-handle error means restart with thestart_*tool. Transitional fallback: if thestart_*tools aren't available on the connector (older server), the blocking twinsget_aggregated_data_by_*/get_distinct_values_by_*still work with the same arguments.
Data-scope discovery — run before any aggregate (reuse anything already discovered this conversation).
- Scenario domain. Pull distinct values of the scenario field (
start_distinct_values_by_alias/_by_id→ poll the matching result tool) — never assume a scenario name exists (Budgetfrequently doesn't; many orgs carry only{Actuals, Forecast}). For budget/plan questions, if no budget-like scenario exists, look for a planning-version-like field (alias/name matching/plan|version|cycle|budget/i) and use its versions as the plan side; if neither exists, say so and offer a comparison across the scenarios that do exist.- Account grain. Pull distinct values of each account-hierarchy level field (L0/L1/L2-like). Use the level whose values partition P&L flows into revenue/COGS/opex-like buckets — on many orgs the top level is the balance-sheet equation (ASSET/LIABILITY/EQUITY/INCOME) and P&L line items live one level deeper. For P&L work, scope to P&L flows and exclude balance-sheet buckets; never present asset/liability/equity totals as revenue or expenses.
- Period scope. Discover the date field's range (distinct values of the reporting-month field, or MIN and MAX in two separate calls — one aggregation per field per call). Default every P&L question to the latest complete fiscal year (or trailing 12 closed months) — never an unscoped all-time total: financials tables are multi-year cumulative and mix balance-sheet stock with P&L flow. Label every output with the period + scenario it covers.
- Reading GROUP BY responses. Each response returns exactly one row per requested group — no subtotal rows and no grand-total row. A total is obtained by summing the rows — there is no total row to read. Null groups arrive explicitly labeled
[null]and are real groups; read null counts from that bucket. Defensive filter: keep only rows in which every requested dimension key is present — a roll-up row omits one or more keys entirely, whereas a genuine null is present with the value[null]. On a correct response this is a no-op; it guards against a stale cached response still carrying legacy subtotal and grand-total rows, each of which equals the whole total and would inflate any sum. When COUNT-ing rows per group, aggregate a different field than the GROUP BY dimension itself — a same-field COUNT of the grouped dimension can 500.- Truncated results. Any data tool may return
{"data": [...], "truncated": true, "total_rows": N, "returned_rows": M, "guidance": "..."}when the result exceeds the response size limit (~100 KB). Thedataprefix is incomplete — never compute totals, shares, or trends from it, and never present it as the full result. Follow theguidance: narrow the query (fewer dimensions, more filters, fewer selected columns) or use a business metric for a named KPI, then re-fetch.
Budget exists)Scenario names vary by org, and a Budget scenario frequently does not exist — filtering on it returns an empty result and a silently wrong analysis. Resolve every side of the comparison against discovered values before any pull:
start_distinct_values_by_alias(alias=<financials_alias>, field=<scenario_field>) if the field is aliased, else start_distinct_values_by_id(table_id=<id>, field_id=<scenario_field_id>) → poll the matching get_distinct_values_result_by_alias/_by_id(handle) until ready (async-fetch pattern; pass limit to the result tool if you need to cap the values)./actual/i./budget|plan|aop|target/i) → use it as the plan-side scenario filter./plan|version|cycle|budget/i). If one exists, pull its distinct values the same way, present the versions, and ask the user which plan version to compare against (default to the most recent only if the user already indicated it). Use {<planning_version_field>: <chosen version>} as the plan-side filter in Step 3 instead of a scenario value — and check which scenario values the rows under that version carry before also filtering on scenario.I found actuals data but couldn't locate any budget or plan data in your financials table — there's no budget-like scenario and no planning-version field.
The available scenarios are: [list the discovered values]
Options:
- Would you like me to compare Actuals against Forecast instead?
- Or should I just show you the Actuals breakdown?
/forecast/i; if absent, drop it from the comparison and say so.--scenarios was passed, map each requested name onto the discovered domain (case-insensitive). Any name with no match goes through the plan-side fallback above — never filter on a scenario value you did not see in the distinct-values response.If the plan side is incomplete (it exists but doesn't cover all the categories or periods that Actuals covers), say so and offer:
Record the resolved sides — actual, plan (scenario value or plan-version filter), forecast — and use them verbatim in every Step 3 pull and in every output label.
Before pulling data, read what is in the active sheet:
Do not assume which mode — read the sheet first.
Issue all scenario pulls in a single turn (concurrent tool calls). Use start_aggregation_by_alias (preferred) or its by-id twin start_aggregation_by_id. For each pull, always include the discovered cost center and report field dimensions alongside the period dimension:
start_aggregation_by_alias(
alias=<financials_alias>,
dimensions=[<period_field>, <cost_center_field>, <report_field>],
metrics=[{"field": <amount_field>, "agg": "SUM"}],
filters=[
{"name": <scenario_field>, "values": [<side resolved in Step 1b>], "is_excluded": false},
{"name": <account_level_field>, "values": [<P&L flow buckets from data-scope item 2>], "is_excluded": false},
{"name": <gaap_field>, "values": [<discovered GAAP-adjustment values>], "is_excluded": true}
]
)→ poll get_aggregation_result_by_alias(handle) until ready (async-fetch pattern) — start all sides first, then poll their handles together.
All filter values are placeholders — every one comes from Step 1b / the data-scope discovery, never from this template. Plan side: when Step 1b resolved a plan-version fallback, replace the scenario filter with {"name": <planning_version_field>, "values": [<chosen version>], "is_excluded": false}. Account scope: use the account-hierarchy level whose discovered values partition P&L flows (data-scope item 2) — on many orgs the top level is the balance-sheet equation and P&L buckets live one level deeper. GAAP exclusion: only if a GAAP-adjustment-like field exists — discover its values first, otherwise omit the filter.
Scope by year either by adding <date_field> to dimensions and filtering the result client-side, or with an advanced date-range filter: {"name": <date_field>, "values": {"type": "advanced", "val": [{"condition": "total_range", "value": ["<jan1_epoch>", "<dec31_epoch>"]}]}} (epoch seconds as strings). Both work — date filtering is no longer rejected.
By-id fallback (no alias): start_aggregation_by_id(table_id=<id>, dimensions=[<period_field_id>, <cost_center_field_id>, <report_field_id>], metrics=[{"field_id": <amount_field_id>, "agg": "SUM"}], filters=[{"field_id": <scenario_field_id>, "values": [...]}]) → poll get_aggregation_result_by_id(handle) until ready (async-fetch pattern). If an alias call 500s on a dimension, re-inspect the Step 1 schema for a sibling and retry, or fall back to the by-id twin.
Assign each distinct pull a Source ID: S1, S2, S3, ...
| Source ID | Side |
|---|---|
| S1 | Actual side (resolved actuals-like scenario) |
| S2 | Plan side (budget-like scenario, or plan-version filter from Step 1b) |
| S3 | Forecast side (if in scope) |
An empty result from a scenario filter is a resolution failure, not a zero plan — go back to Step 1b rather than presenting $0 as the plan side.
Also pull HeadCount whenever compensation lines are in scope and the org has a headcount-like table (discover it via list_data_models; if none exists, skip the decomposition and say so). Comp $ alone cannot distinguish volume (headcount change) from rate (salary / bonus change) — always decompose. See the HC inference trap in datarails-excel-multi-table-analysis.
Verify data freshness for each table before quoting period boundaries. If a table is short of the requested period, narrow the window and flag it in the Read Me block and in each affected commentary row.
Add two columns to the right of the user's range (or include them in the fresh block):
| ... existing columns ... | Commentary | Source Ref |
|---|
Commentary cell rules:
[S1, S2]Example commentary cells (illustrative — <report_field> and the plan-side label come from Step 1 / Step 1b):
Marketing expense +$120K (+14%) vs Plan, driven by Events cost center (+$95K, <report_field>: T&E); remaining $25K in Digital/Software. [S1, S2]
R&D compensation +$280K (+18%) vs Plan: $230K (82%) is rate-driven (Q4 bonus accrual on flat HC of 23), $50K (18%) is volume (2 new hires in Oct). [S1, S2, S3]
Source Ref cell: bracketed refs only — [S1, S2]. No commentary in this cell.
Citing live DR-formula reads (Excel context): When a $ figure comes from reading a DR cell (
agent.read_range/agent.evaluate_drget) rather than astart_aggregation_by_aliaspull, cite the cell'sdata.sources[](sheet!cell + widget) returned by the read — every $ figure must carry a source. If a read returns emptysources, do not present the figure; re-read or tell the user it isn't Datarails-tracked.
Sources sheet (Sources tab — create if it doesn't exist):
| Source ID | Table | Table ID | Dimensions | Metrics | Filters | Period Window | Rows | Freshness Note |
|---|
One row per distinct Source ID. Every [S#] cited in Commentary must have a matching row here.
Read Me block (top of the analysis sheet, above the data):
SCOPE & FILTERS
Period: <explicit window, e.g., 2025-01 through 2025-09>
Scenarios: <resolved sides from Step 1b, e.g., Actuals vs "FY25 Plan v2" (plan version) vs Forecast>
Accounts: <account_level_field> = <discovered P&L flow buckets>; <gaap_field> excluded (if present)
Cost Center field: <discovered field name>
Report Field: <discovered field name>
SOURCE TABLES (full detail in Sources sheet)
S1 — Financials: actual side by period × cost center × report field
S2 — Financials: plan side (budget-like scenario or plan version) by period × cost center × report field
S3 — HeadCount: FTE by month × cost center (if applicable)
DATA FRESHNESS
Financials: <latest loaded period>
HeadCount: <latest loaded period, or "N/A">Excel context + enrichment mode only. Two conditions must both be true:
agent.get_session succeeded in Step 0 (Excel context confirmed).DR.GET, DR.QTD, DR.YTD, DR.MTD, etc. — any DR function) that you added commentary alongside.If either condition is false, skip this step entirely and state why:
drilldown_list not available.start_aggregation_by_alias → get_aggregation_result_by_alias), not DR formulas. drilldown_list targets DR formula cells — it has nothing to act on. Tell the user drill-down is unavailable in this mode.Drill-down works on any DR function cell, not just
DR.GET. The cell must resolve to a Datarails widget (DR.GET/QTD/YTD/MTD/...). Before firing, ifisConnectedis false, confirmconnect_filewith the user (see Step 0).
If both conditions are true: after writing commentary, you MUST output a Drill-Down Menu block directly in chat. Do not end the response without it.
The block must list every row where |variance| > 10% or flagged unfavorable, one row per line with its Δ$ and Δ%:
📋 Drill-Down Menu — rows with |variance| > 10%
1. <Row label> Δ$X.XM (ΔY%)
2. <Row label> Δ$X.XM (ΔY%)
...Then explicitly ask: "Which rows would you like me to drill into for a cell-level breakdown? I'll call drilldown_list on each selected row."
Only invoke drilldown_list (params: sheetName, cellAddress; timeoutMs: 180000) after the user selects rows — do not drill automatically.
Budget frequently doesn't exist; resolve every side in Step 1b first[S#] ref — every claim must be auditableVariance math rules (all modes):
[null] bucket; when COUNT-ing rows per group, aggregate a different field than the GROUP BY dimension itself.When generating Excel or PowerPoint files, apply Datarails brand styling:
Font: Poppins (fall back to Calibri if unavailable). Weights: 400 regular, 600 semibold, 700 bold.
Colors:
| Role | Hex | Use |
|---|---|---|
| Navy | 0C142B | Header/banner background |
| Main text | 333333 | Primary text |
| Secondary | 6D6E6F | Muted/subtitle text |
| Border | 9EA1AA | Cell borders |
| Section bg | F2F2FB | Section header / row header background (lavender) |
| Input bg | EAEAFF | Editable/input cell background |
| Input text | 4646CE | Editable cell text (indigo) |
| Favorable | 2ECC71 | Positive variance / good KPI delta |
| Unfavorable | E74C3C | Negative variance / bad KPI delta |
| Chart 1 | 0C142B | Actuals (navy) |
| Chart 2 | F93576 | Budget (hot pink) |
| Chart 3 | 00B4D8 | Teal |
| Chart 4 | FFA30F | Amber |
Excel layout:
Number formats: _(* #,##0_);_(* (#,##0);_(* "-"_);_(@_) (default), $#,##0 (dollars), $#,##0.0,,"M" (millions), 0.0% (percent)
Variance coloring: Any cell showing a delta/change: green (2ECC71) if favorable, red (E74C3C) if unfavorable. Apply automatically based on value sign and metric context.
PowerPoint: Navy (0C142B) background, 16:9 widescreen, Poppins font, white text, amber (FFA30F) accent lines, card backgrounds 001F37.
If asked to add live / refreshable Datarails formulas (DR.GET) to a generated workbook, the only valid form is:
=DR.GET(Value, "[DimensionName]", CellRef, "[DimensionName]", CellRef, ...)=DR.GET(Value,"financials","Amount","SUM",...)
is invented syntax that the Datarails Add-in cannot parse or refresh."[Scenario]").
Dimension values are always cell references, never hardcoded strings.Value
referring to the string constant "Value"
(wb.defined_names.add(DefinedName("Value", attr_text='"Value"'))) —
otherwise Excel autocorrects the bare token to its built-in VALUE() and
the formula breaks.=DR.GET(...) only — never wrapped in IFERROR/IF/ROUND.The get-formula skill (/dr-get-formula) is the full reference — parameter
cells, validated dimension values, report layouts. Prefer it for whole formula
workbooks; apply this contract when adding DR.GET formulas to a workbook here.
/dr-forecast-variance --year 2025/dr-forecast-variance --year 2025 --scenarios Actuals,BudgetScenario names passed via
--scenariosare resolved against the discovered scenario domain (Step 1b). If a requested name (e.g.Budget) doesn't exist on the org, the plan side falls back to a planning-version field — or you'll be shown the scenarios that do exist and offered Actuals vs Forecast.
/dr-forecast-variance --year 2025 --period 2025-Q4/dr-forecast-variance --year 2025 \
--output-xlsx reports/variance_2025.xlsx \
--output-pptx reports/variance_summary.pptx# Compare latest actuals vs budget
/dr-forecast-variance --year 2025 --scenarios Actuals,Budget# Compare forecast prediction accuracy
/dr-forecast-variance --year 2025 --scenarios Actuals,Forecast# Complete comparison for board review
/dr-forecast-variance --year 2025 --scenarios Actuals,Budget,Forecast# Analyze departmental performance vs budget
/dr-forecast-variance --year 2025# Professional variance analysis for stakeholders
/dr-forecast-variance --year 2025FP&A Process:
1. /dr-extract --year 2025 (Get actuals)
2. /dr-reconcile --year 2025 (Validate data)
3. /dr-forecast-variance --year 2025 (Analyze variances)
4. Present findings to leadership (Board meeting)# Monthly variance tracking
/dr-forecast-variance --year 2025 --scenarios Actuals,Forecast --period 2025-01
/dr-forecast-variance --year 2025 --scenarios Actuals,Forecast --period 2025-02
# Compare forecast accuracy trend/dr-forecast-variance --year 2024 --scenarios Actuals,Budget
/dr-forecast-variance --year 2025 --scenarios Actuals,Budget
# Compare year-over-year performance# Three different budget scenarios
/dr-forecast-variance --year 2025 --scenarios Actuals,Budget_Conservative,Budget_Aggressive"Scenario not found" - The requested name isn't in the discovered scenario domain. Re-run Step 1b: show the scenarios that DO exist, offer them, and try the planning-version fallback for the plan side.
"No variance data" - A side returned empty. If the plan-side pull filtered on a scenario value that was never discovered (e.g. Budget), that's the bug — go back to Step 1b; plan data often lives in a planning-version field, not a scenario.
"Large variance" - Review detailed Excel report for root causes
Works with:
/dr-extract - Source of scenario data/dr-insights - Contextual trend analysis/dr-reconcile - Validate scenario consistency/dr-dashboard - Current performance viewagent.get_session — verify Excel context + login state (Step 0)connect_file — connect workbook; only needed for create_dynamic_range / drilldown_*, confirm with user firstrefresh_ribbon — refresh stale data before pulling (timeoutMs: 600000); ask once per sessionagent.read_range / agent.evaluate_drget — read a DR formula cell's value + data.sources[] for citationdrilldown_list — cell-level breakdown on large-variance rows after analysis (timeoutMs: 180000)publish_to_dashboard — publish summary range to a Datarails dashboard (mutating, confirm first)/dr-extract - Extract scenario data/dr-insights - Understand drivers of variances/dr-reconcile - Validate data consistency/dr-dashboard - Real-time performancedatarails-excel-multi-table-analysis - Row-commentary pattern used in Excel context mode (per-row attribution, source refs, Sources sheet, HC decomposition)0a038a3
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.