Datarails Financial Data Extraction
Extract validated financial data from Finance OS to Excel workbooks. Data is pulled via MCP tools and the workbook is built locally with openpyxl. No server-side rendering.
The workbook contains:
- P&L Data: Revenue, COGS, Operating Expenses by month
- KPI Data: quarterly KPIs the org's data can actually source — revenue by quarter always; SaaS metrics (ARR, Net New ARR, Churn, LTV) only when a KPI source exists (see the KPI-honesty rule under Sheets to Generate)
- Validation: Cross-checks between P&L and KPI tables
Excel Context — Routing Preamble
Before any data pull, establish whether this skill is running in a live Excel context
(Claude for Excel with the Datarails Add-In loaded) and route accordingly.
Detect — never infer from the user's wording. A sheet list containing __dr_agent
means the add-in is loaded. Confirm with the agent.get_session probe, which you run by
executing Office.js through the execute_office_js tool (see the Excel Context Contract
in CLAUDE.md, §Transport) — it is not an MCP tool and has no MCP equivalent.
A failed probe is a normal detection result, not an error: it means "no bridge here",
which is the expected outcome in Claude Code. Do not surface it, do not retry it, and do
not apply this skill's connection-error or Connectors-UI guidance to it — that guidance is
about datarails-finance-os connector calls only.
A successful probe means Excel context, on either transport. The bridge serves two
add-in tracks and their session payloads differ: Flex (Office.js task pane) exposes
isLoggedIn; the COM desktop add-in — the majority of live workbooks — exposes
isConnected instead, and isConnected: false is not a login failure, an error, or a
reason to stop or send the user anywhere. It merely means the workbook isn't connected
to a Datarails file, which matters only to drilldown_* / create_dynamic_range (the
bridge skill gates those itself). Only Flex's explicit isLoggedIn: false means
sign-in is needed.
Route by the target of the request, not by whether a workbook is open.
- Org / server data — which tables, models and fields exist, aggregations, raw rows,
distinct values, metrics, profiling — always the
datarails-finance-os MCP connector,
even in Excel. The bridge cannot answer these.
- Workbook actions — refresh, drill a cell, insert a DR function, read what a cell
returns, publish, submit — always the add-in bridge. Never a native Excel recalc
(
calculate(), F9): it does not pull Datarails data and silently yields stale values.
In a live Excel context this skill cannot produce its file deliverable. Its generation
steps depend on the Bash tool, which that surface does not provide. Say so plainly and
offer the real alternatives — a scoped answer in chat, or re-running this skill from
Claude Code where file output works. Never improvise another route to a file, never hand
back a partial artifact, and never silently substitute a different deliverable: writing
into someone's live workbook instead of giving them the file they asked for is a
different and irreversible outcome, not a smaller version of the same one.
If you do write DR formulas into the workbook, writing and refreshing are one atomic
step. Write to a new sheet, fire refresh_selected_cells_ribbon scoped to that
range — a new-sheet block is one contiguous range, so one scoped call covers any cell
count — then read the range back. refresh_ribbon is not the tool for this: it repulls
every DR cell in the file and can silently move numbers elsewhere in the user's model.
It is reserved for the one case the scoped command can't cover — scattered inserts
across multiple sheets, per excel-context__internal's refresh-after-insert rule — and
even then only with the user's explicit OK, after snapshotting the DR ranges you can
bound, reporting each changed cell in them before → after with the compared ranges
named, and saying plainly that cells beyond them may also have updated. If the user
declines the whole-workbook refresh, fall back to scoped refresh_selected_cells_ribbon
calls sheet-by-sheet — slower, but nothing outside the written cells moves. A freshly
written DR formula reads Missing / Loading… / #BUSY! until an agent refresh lands,
so never quote a value you have not read back after a successful refresh, and never
present a figure fetched from the MCP connector as though it were the cell's value.
/dr-get-formula is the full authority for DR.GET workbooks.
If the user asks you to elaborate on a DR-backed figure — "explain", "break down",
"what's driving this", "why is X" — and the figures in scope are DR formula cells, offer
the add-in's drill-down instead of silently re-deriving the number through the MCP
connector. A drill resolves the exact filters behind that cell; a hand-rebuilt query only
approximates them.
Arguments
| Argument |
Description |
Default |
--output <file> |
Output filename |
tmp/Financial_Extract_YYYY.xlsx |
--scenario <name> |
Primary scenario |
The discovered actuals-like scenario (a passed name is validated against the discovered scenario domain) |
--year <YYYY> |
Calendar year to extract |
Latest complete fiscal year in the data (per the data-scope discovery in Step 2) |
Workflow
Step 1: Verify Connection
If a datarails-finance-os connector call fails with an authentication or connection error, tell the user:
The Datarails connector isn't connected. Click the "+" button next to the prompt, select Connectors, find Datarails, and click Connect.
Then STOP — do not retry until the user has reconnected.
(A failed agent.get_session probe is not this case — that is normal Excel-context
detection, handled by the routing preamble above, and never a reason to send the user
to Connectors UI.)
Step 2: Discover the financials table, its fields, and (if present) a KPI table
If you already discovered these earlier in THIS conversation, reuse them —
skip to Step 3. Discovery is cheap but not free; do it once per
conversation, then carry the values forward.
list_data_models. Pick the financials table: the one whose name (or
alias) matches /financial|cube|p&?l|ledger|gl/i; if none match, the largest
by row count. Note both its numeric id (call it <financials_table_id>)
and its alias (call it <financials_alias>; may be empty). Prefer the
alias path when an alias exists — friendlier field names, far fewer tokens.
Also note any KPI / metrics table — name (or alias) matches
/kpi|metric|saas/i — as <kpis_table_id> / <kpis_alias> if one exists. If
none does, KPI sheets are built only from whatever metrics live in the
financials table (or omitted).
Fields. If the table has an alias, list_aliased_fields(<financials_alias>);
otherwise get_fields_by_id(<financials_table_id>) (capture each field's
numeric id — the by-id tools address fields by id). Bind these by
case-insensitive match on the field alias/name (respecting the noted type):
<amount_field> — numeric: ^amount$ → transaction_amount → value
<scenario_field> — categorical: ^scenario$ → ^version$
<month_field> — date/period: reporting_date → posting_date → ^month$ → ^date$
<account_l1_field> — dr_acc_l1 → account_l1 → account_group_l1
<account_l2_field> — dr_acc_l2 → account_l2 → account_group_l2
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 numeric id and its alias (empty if none). Address a field by alias (via the *_by_alias tools) when it has one, else by numeric id (via the *_by_id tools). By-id always works — never abandon the query because the aliased set is thin.
If <kpis_table_id> exists, list_aliased_fields(<kpis_alias>) (or
get_fields_by_id(<kpis_table_id>)) and bind:
<metric_name_field> — ^metric$ → metric_name → kpi_name
<quarter_field> — ^quarter$ → quarter → the KPI table's date field
<kpi_value_field> — numeric: ^value$ → ^amount$
If <amount_field> or <scenario_field> has no clear match, ask the user
which field to use, then continue.
Find the account category values needed for the P&L and Balance Sheet
sections: start_distinct_values_by_alias(<financials_alias>, <account_l1_field>)
(or start_distinct_values_by_id(<financials_table_id>, <account_l1_field_id>))
→ poll the matching get_distinct_values_result_by_alias/_by_id(handle)
until ready (async-fetch pattern).
If the distinct call errors, fall back to
get_data_by_alias(<financials_alias>, select=[<account_l1_field>], limit=500)
(or the by-id twin) and collect the distinct values. Match:
<revenue_value> ← /revenue|sales|income/i
<cogs_value> ← /cogs|cost of goods|cost of sales|direct cost/i
<opex_value> ← /operating|opex|expense|sg&a/i
- balance-sheet categories ←
/asset|liabilit|equity/i
If a category has several candidates, pick the broadest top-level one; if
genuinely ambiguous, ask the user once. If the L1 values partition as the
balance-sheet equation (asset / liability / equity / income-like) rather
than P&L buckets, the P&L categories live one level deeper — pull the
<account_l2_field> distinct values and match <revenue_value> /
<cogs_value> / <opex_value> there instead (see the data-scope
discovery below).
Aggregation-field failures are handled reactively, not pre-probed (see Step 3).
Async fetch — aggregations and distinct values run as start → poll. start_aggregation_by_id/_by_alias and start_distinct_values_by_id/_by_alias take 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 that handle back verbatim to the matching get_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, pass limit to the result tool). An expired/unknown-handle error means restart with the start_* tool. Transitional fallback: if the start_* tools aren't available on the connector (older server), the blocking twins get_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 (Budget frequently 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 mixed into the
data list; grand totals arrive in a separate top-level totals field beside the rows ({"data": [...], "totals": {...}}), computed across all groups, not just the returned prefix. For a grand total, read totals — never sum the rows when the response carries truncated: true (summing the returned prefix silently under-counts; dev repro: 474 of 31,455 rows summed to 21% of the true total). totals combines the per-group results rather than re-scanning the rows, so it is exact exactly when the aggregation is decomposable: SUM (sum of the group sums), COUNT (sum of the group counts), MIN, and MAX. It is WRONG for AVG (unweighted mean of the group averages) and COUNT_UNIQUE (sum of the per-group distinct counts, so a value recurring across groups is counted once per group) — true average = SUM total ÷ COUNT total (two calls: a field may be aggregated at most once per request); true distinct count = the distinct-values tools. Treat every aggregation type not named exact above — UNIQUE_VALUES included, whose cross-group de-duplication is unverified (the COUNT_UNIQUE behaviour above is evidence the engine may not de-duplicate across groups at all) — as not decomposable: derive it from complete rows or the distinct-values tools, never from totals. totals is absent on dimension-less aggregations (the single returned row IS the total) and may be absent on responses cached before the rollout (cache TTL ≤ 7 days) — only in those two cases is a total obtained by summing complete (untruncated) rows. 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 (~50 KB). The data prefix is incomplete — never compute totals, shares, or trends from it, and never present it as the full result. On aggregations the top-level totals field is unaffected by truncation (computed across all groups, not just the returned prefix) — read grand totals from it instead of re-fetching. Narrow the query (fewer dimensions, more filters, fewer selected columns — or a business metric for a named KPI) and re-fetch only when the rows themselves are needed beyond the cap; with totals present, a SUM/COUNT/MIN/MAX grand total never requires a re-fetch or chunking by dimension (AVG, COUNT_UNIQUE and UNIQUE_VALUES never read totals — true average = SUM total ÷ COUNT total from two calls; true distinct count = the distinct-values tools). A truncated response without totals (pre-rollout cache) cannot answer a grand-total question from its prefix. Re-run the aggregation once — a fresh run may miss the stale entry and return totals. If the re-run still carries no totals, stop re-running and fall back to narrowing or chunking by dimension until the responses are complete, then sum those rows. Never total the prefix.
Step 3: Fetch Data via MCP
Aggregation-first. Run in parallel where possible. Use the alias path
(start_aggregation_by_alias) when <financials_alias> exists; otherwise the
by-id twin (start_aggregation_by_id).
- Monthly P&L —
start_aggregation_by_alias(<financials_alias>, dimensions=[<account_l1_field>, <account_l2_field>, <month_field>], metrics=[{"field": <amount_field>, "agg": "SUM"}], filters=[{"name": <scenario_field>, "values": [<--scenario> or <discovered actuals-like scenario>], "is_excluded": false}]) → poll get_aggregation_result_by_alias(handle) until ready
(async-fetch pattern). First validate the scenario against the domain from the
data-scope discovery — if it isn't there, list the scenarios that do exist
and ask rather than running an empty extract. Scope to --year with an
advanced date filter (see below) or by filtering the <month_field>
dimension client-side; when --year wasn't given, use the latest complete
fiscal year from the discovered date range — never an unscoped all-time
pull.
- Balance Sheet items — same table grouped by
[<account_l1_field>, <account_l2_field>, <month_field>], filtered client-side to the balance-sheet
account categories found in Step 2.3.
- Quarterly KPIs — only if
<kpis_table_id> was found:
start_aggregation_by_alias(<kpis_alias>, dimensions=[<metric_name_field>, <quarter_field>], metrics=[{"field": <kpi_value_field>, "agg": "SUM"}], …)
→ poll get_aggregation_result_by_alias(handle) until ready (async-fetch
pattern) (or the by-id twins), for --year and the prior year (for YoY). For named
KPIs that aren't in a table (e.g. ARR), call list_business_metrics to
check whether the org defines them as populated metrics — but this skill's
toolset reads KPI values only from tables, so a KPI found in neither the
KPI table nor the P&L-derivable grain is omitted from the workbook
(see the KPI-honesty rule under Sheets to Generate). Never back into a
SaaS metric by aggregating the P&L.
- Distinct values for validation — derive the distinct
<scenario_field>,
<month_field>, and <account_l1_field> values from the aggregation results,
or run the distinct-values start→poll tools (start_distinct_values_by_alias
/ start_distinct_values_by_id → the matching result tool) directly,
to confirm the extract covers the expected dimensions.
Reading the responses: apply rule 4 of the data-scope discovery to every
aggregation payload — every row is a real group and no total row is appended
to the rows. Monthly totals, subtotals, and YoY math come from your own sum of
complete rows (never a truncated prefix — narrow and re-fetch); grand totals
for the validation sheet read from the top-level totals field. Treat
[null] groups as their own explicit
bucket.
Filter rules:
- Date ranges filter directly via an advanced filter — no epoch workaround
needed. To scope to
--year, pass {"name": <month_field>, "values": {"type": "advanced", "val": [{"condition": "total_range", "value": ["<jan1_epoch>", "<dec31_epoch>"]}]}} (epoch seconds as strings; by-id uses the field_id
form). Jan 1–Dec 31 assumes a calendar-aligned fiscal year — if the org's
discovered fiscal calendar is offset, use its fiscal-year boundary dates
instead. Adding <month_field> as a dimension and filtering by --year
client-side still works and is optional.
- Value-list filters take
values: [...] (set is_excluded: true for
NOT-IN); advanced filters also support comparisons, ranges, text matching, and
null checks.
If an aggregation call fails on a dimension field with a 500: that field
isn't usable as a dimension for this client. Re-inspect the Step 2 schema for
a sibling account-level field from the discovered schema (orgs often carry
in-between levels, or an account_group_l1-style alternative)
and retry with it. If the alias call errors, retry the by-id twin. If no sibling
works, tell the user which field failed.
Auto-refresh tokens are handled by the MCP layer; fall back to
get_data_by_alias / get_data_by_id with paging only if aggregation fails
outright. On "truncated": true, the returned rows are an incomplete prefix —
narrow the query per the guidance (more filters / fewer columns / lower
limit+offset paging) and re-fetch; never present the prefix as complete. (When
only a grand total is needed — e.g. for the validation sheet — read the
top-level totals field instead of re-fetching; see preamble item 5.)
Step 4: Build the Workbook Locally
Generate the xlsx with openpyxl. Do not call any server-side workbook tool — they have been removed.
Write a single Python script and execute it via Bash. The script reads a JSON payload of the extracted data and writes the xlsx.
If openpyxl is missing:
- Claude Code:
pip install openpyxl.
- Claude.ai web / ChatGPT: openpyxl is preinstalled in the analysis/code interpreter sandbox.
Sheets to Generate
Render only KPIs you can source. A KPI may come from (a) the org's metric catalog — list_business_metrics (ungated) for discovery; the get_business_metric_* data tools are feature-gated and may be absent, and USER-kind metrics often return empty — or (b) aggregation over the discovered P&L grain (revenue, expense buckets, gross/operating margin when COGS/OpEx-like buckets exist). SaaS/unit-economics metrics (ARR, MRR, churn, LTV, CAC, burn, runway, NRR) are not derivable from a P&L table — include them only if discovered as populated metrics; otherwise omit the card/slide entirely. Never render a placeholder, estimate, or fabricated value for a KPI you could not source.
Summary
- Year, scenario, generation timestamp.
- Totals: Revenue, COGS, Gross Profit, Gross Margin %, Operating Expenses, Operating Income, Net Income — each labeled with the period + scenario it covers (e.g. "FY · "), never presented as bare all-time numbers.
- Balance Sheet snapshot: Total Assets, Total Liabilities, Total Equity (period-end, labeled with the as-of month).
- KPI snapshot: only KPIs actually sourced per the rule above; drop the block entirely if none were sourceable.
- Row-by-row validation checks (see sheet 4) summarized as PASS / FAIL count.
P&L
- Rows: account categories discovered in Step 2.3, indented by L1/L2.
- Columns: 12 months + Total + Prior Year + YoY Δ%.
- Subtotals (Revenue, COGS, Gross Profit, Total OpEx, Operating Income) bolded.
- Number format:
$#,##0 for dollars, 0.0% for percentages.
KPIs (only when at least one KPI was sourced — otherwise omit the sheet)
- One row per sourced metric. Columns: Q1 / Q2 / Q3 / Q4 / FY / Prior FY / YoY Δ%.
- Candidate SaaS metrics (ARR, Net New ARR, Gross/Net Churn %, LTV, CAC, LTV/CAC, NRR, GRR) appear only when sourced per the rule above; P&L-derivable KPIs (revenue, gross/operating margin) are always fair game. No placeholder or estimated rows or columns — omit them instead.
Validation
- One row per cross-check:
- "P&L Revenue total equals KPI Revenue total" → PASS/FAIL with both values (skip when no KPI source exists).
- "Sum of monthly Revenue equals annual Revenue" → PASS/FAIL.
- "Cross-grain checksum" — the P&L total computed from the monthly rows matches the same window aggregated at a single grain (one call, no month dimension) → PASS/FAIL. (Replaces the old grand-total-row check: responses no longer append a total row.)
- "All 12 months present in extract" → PASS/FAIL.
- "Scenario coverage" — list distinct scenarios and confirm
--scenario is among them.
- "Discovered field coverage" — list the fields bound in Step 2 and confirm each returned data.
- Footer: generation timestamp.
Datarails Brand Styling
Apply the same brand styling block as /dr-insights and /dr-intelligence:
Font: Poppins (fall back to Calibri). Weights: 400 / 600 / 700.
Colors:
| Role |
Hex |
| Navy (header bg) |
0C142B |
| Main text |
333333 |
| Secondary text |
6D6E6F |
| Border |
9EA1AA |
| Section bg (lavender) |
F2F2FB |
| Input bg |
EAEAFF |
| Input text (indigo) |
4646CE |
| Favorable |
2ECC71 |
| Unfavorable |
E74C3C |
| Validation PASS |
2ECC71 |
| Validation FAIL |
E74C3C |
Layout:
- Content starts at column B (column A narrow gutter).
- Rows 1-6 header banner: navy background, white title, white subtitle (year + scenario).
- Gridlines OFF. Freeze panes at B7.
- Footer row: generation date + "Datarails Financial Extract".
- Every cell needs font, fill, alignment, number format.
Number formats: _(* #,##0_);_(* (#,##0);_(* "-"_);_(@_) (default), $#,##0 (dollars), 0.0% (percent).
Variance coloring: YoY Δ% cells use green (2ECC71) for favorable, red (E74C3C) for unfavorable. For expenses, lower is favorable; for revenue/margin, higher is favorable.
DR.GET Formulas — Authoring Contract
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, ...)
- Never transliterate an MCP/API call into a formula. DR.GET takes no
table, field, or aggregation arguments —
=DR.GET(Value,"financials","Amount","SUM",...)
is invented syntax that the Datarails Add-in cannot parse or refresh.
- Dimension names go in square brackets inside quotes (
"[Scenario]").
Dimension values are always cell references, never hardcoded strings.
- Date cells referenced by formulas hold end-of-month date serials
computed from the calendar — never raw epoch timestamps from API responses
(epochs land a day early with a time component and never match).
- Before writing any formula, create the workbook-scoped defined name
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.
- Bare
=DR.GET(...) only — never wrapped in IFERROR/IF/ROUND.
- Every rule here applies to the retrieval/period family —
DR.GET,
DR.QTD, DR.YTD, DR.MTD share one form (=DR.QTD(Value, "[Dim]", CellRef, ...)), one cell-reference discipline, one Value defined-name
requirement, one no-wrapping rule. "DR.GET" in this contract means that
family. Helper functions with their own documented signatures (e.g.
DR.INCLUDE, DR.RANGE) are not covered here — author those only from
their own documentation, never by analogy with this form.
- In a live Excel context, writing DR formulas and refreshing them is one
atomic step — a freshly written DR cell reads
Missing until an agent
refresh lands, and only read-back values may be quoted. The Excel-context
routing preamble (or the skill's own Step 0 workflow) owns that procedure;
this contract owns the formula text.
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 any retrieval/period DR formula
(DR.GET/DR.QTD/DR.YTD/DR.MTD) to a workbook here.
Step 5: Output
- Claude.ai web / ChatGPT: present the xlsx as a downloadable artifact.
- Claude Code: print the absolute path.
Always include in the summary:
- Output file path
- Year and scenario extracted (every quoted total carries this label)
- Validation result count (e.g. "5/5 PASS")
- Any warnings (missing months, scenario gaps, KPIs omitted as unsourceable)
Troubleshooting
No table matches the financials pattern (Step 2)
- List the tables you found and ask the user which one holds their P&L /
financial data, then continue.
Extract comes back empty for the requested scenario
- Re-check the scenario domain from the data-scope discovery: the scenario
name you filtered on may simply not exist in this org (budget-like data
often lives in a planning-version field instead). Offer the scenarios that
do exist.
Aggregation rejected on a dimension field (500)
- Swap to a sibling field from the Step 2 schema and retry (see Step 3). If
no sibling works, tell the user which field failed.
Token expires during extraction
- The MCP layer auto-refreshes. If 401 errors persist, reconnect via Connectors UI.
Missing months in data
- Check the
<month_field> type. If the API stores year as a string, ensure the client-side --year comparison is against "2025" not 2025.
openpyxl not available
- Claude Code:
pip install openpyxl.
- Claude.ai / ChatGPT: should be preinstalled in code-execution sandbox.
Related Skills
/dr-tables — Explore available tables.
/dr-query — Investigate specific records.
/dr-intelligence — Full 10-sheet insights workbook (this skill is the simpler 4-sheet variant).
/dr-insights — Executive PowerPoint + Excel combo.
1---2name: dr-extract3description: Extract validated financial data from Datarails Finance OS to Excel — a RAW FULL-YEAR export — a 4-sheet workbook (P&L, Balance Sheet, KPIs including SaaS metrics where sourceable from the org's data, and validation checks); no analysis or narrative. For an analyzed workbook use intelligence; for an executive deck use insights. Self-contained — discovers the client's tables and fields on its own, no profile or setup step required.4---56# Datarails Financial Data Extraction78Extract validated financial data from Finance OS to Excel workbooks. Data is pulled via MCP tools and the workbook is built locally with openpyxl. No server-side rendering.910The workbook contains:11- **P&L Data**: Revenue, COGS, Operating Expenses by month12- **KPI Data**: quarterly KPIs the org's data can actually source — revenue by quarter always; SaaS metrics (ARR, Net New ARR, Churn, LTV) only when a KPI source exists (see the KPI-honesty rule under Sheets to Generate)13- **Validation**: Cross-checks between P&L and KPI tables1415## Excel Context — Routing Preamble1617Before any data pull, establish whether this skill is running in a **live Excel context**18(Claude for Excel with the Datarails Add-In loaded) and route accordingly.1920**Detect — never infer from the user's wording.** A sheet list containing `__dr_agent`21means the add-in is loaded. Confirm with the `agent.get_session` probe, which you run by22executing Office.js through the `execute_office_js` tool (see the Excel Context Contract23in CLAUDE.md, §Transport) — it is not an MCP tool and has no MCP equivalent.24**A failed probe is a normal detection result**, not an error: it means "no bridge here",25which is the expected outcome in Claude Code. Do not surface it, do not retry it, and do26not apply this skill's connection-error or Connectors-UI guidance to it — that guidance is27about `datarails-finance-os` connector calls only.28**A successful probe means Excel context, on either transport.** The bridge serves two29add-in tracks and their session payloads differ: Flex (Office.js task pane) exposes30`isLoggedIn`; the COM desktop add-in — the majority of live workbooks — exposes31`isConnected` instead, and **`isConnected: false` is not a login failure, an error, or a32reason to stop or send the user anywhere**. It merely means the workbook isn't connected33to a Datarails file, which matters only to `drilldown_*` / `create_dynamic_range` (the34bridge skill gates those itself). Only Flex's explicit `isLoggedIn: false` means35sign-in is needed.3637**Route by the target of the request, not by whether a workbook is open.**3839- **Org / server data** — which tables, models and fields exist, aggregations, raw rows,40 distinct values, metrics, profiling — always the `datarails-finance-os` MCP connector,41 **even in Excel**. The bridge cannot answer these.42- **Workbook actions** — refresh, drill a cell, insert a DR function, read what a cell43 returns, publish, submit — always the add-in bridge. Never a native Excel recalc44 (`calculate()`, F9): it does not pull Datarails data and silently yields stale values.4546**In a live Excel context this skill cannot produce its file deliverable.** Its generation47steps depend on the `Bash` tool, which that surface does not provide. Say so plainly and48offer the real alternatives — a scoped answer in chat, or re-running this skill from49Claude Code where file output works. Never improvise another route to a file, never hand50back a partial artifact, and never silently substitute a different deliverable: writing51into someone's live workbook instead of giving them the file they asked for is a52different and irreversible outcome, not a smaller version of the same one.5354**If you do write DR formulas into the workbook, writing and refreshing are one atomic55step.** Write to a **new sheet**, fire `refresh_selected_cells_ribbon` scoped to that56range — a new-sheet block is one contiguous range, so one scoped call covers any cell57count — then read the range back. `refresh_ribbon` is not the tool for this: it repulls58every DR cell in the file and can silently move numbers elsewhere in the user's model.59It is reserved for the one case the scoped command can't cover — scattered inserts60across multiple sheets, per `excel-context__internal`'s refresh-after-insert rule — and61even then only with the user's explicit OK, after snapshotting the DR ranges you can62bound, reporting each changed cell in them before → after with the compared ranges63named, and saying plainly that cells beyond them may also have updated. If the user64declines the whole-workbook refresh, fall back to scoped `refresh_selected_cells_ribbon`65calls sheet-by-sheet — slower, but nothing outside the written cells moves. A freshly66written DR formula reads `Missing` / `Loading…` / `#BUSY!` until an agent refresh lands,67so never quote a value you have not read back after a successful refresh, and never68present a figure fetched from the MCP connector as though it were the cell's value.69`/dr-get-formula` is the full authority for DR.GET workbooks.7071**If the user asks you to elaborate on a DR-backed figure** — "explain", "break down",72"what's driving this", "why is X" — and the figures in scope are DR formula cells, offer73the add-in's drill-down instead of silently re-deriving the number through the MCP74connector. A drill resolves the exact filters behind that cell; a hand-rebuilt query only75approximates them.76<!-- end:excel-context-preamble -->7778## Arguments7980| Argument | Description | Default |81|----------|-------------|---------|82| `--output <file>` | Output filename | `tmp/Financial_Extract_YYYY.xlsx` |83| `--scenario <name>` | Primary scenario | The discovered actuals-like scenario (a passed name is validated against the discovered scenario domain) |84| `--year <YYYY>` | Calendar year to extract | Latest complete fiscal year in the data (per the data-scope discovery in Step 2) |8586## Workflow8788### Step 1: Verify Connection8990If a `datarails-finance-os` **connector** call fails with an authentication or connection error, tell the user:9192> The Datarails connector isn't connected. Click the **"+"** button next to the prompt, select **Connectors**, find **Datarails**, and click **Connect**.9394Then STOP — do not retry until the user has reconnected.9596(A failed `agent.get_session` probe is **not** this case — that is normal Excel-context97detection, handled by the routing preamble above, and never a reason to send the user98to Connectors UI.)99100### Step 2: Discover the financials table, its fields, and (if present) a KPI table101102**If you already discovered these earlier in THIS conversation, reuse them —103skip to Step 3.** Discovery is cheap but not free; do it once per104conversation, then carry the values forward.1051061. `list_data_models`. Pick the **financials** table: the one whose name (or107 alias) matches `/financial|cube|p&?l|ledger|gl/i`; if none match, the largest108 by row count. Note **both** its numeric `id` (call it `<financials_table_id>`)109 and its `alias` (call it `<financials_alias>`; may be empty). **Prefer the110 alias path when an alias exists** — friendlier field names, far fewer tokens.111 Also note any **KPI / metrics** table — name (or alias) matches112 `/kpi|metric|saas/i` — as `<kpis_table_id>` / `<kpis_alias>` if one exists. If113 none does, KPI sheets are built only from whatever metrics live in the114 financials table (or omitted).1151162. Fields. If the table has an alias, `list_aliased_fields(<financials_alias>)`;117 otherwise `get_fields_by_id(<financials_table_id>)` (capture each field's118 numeric `id` — the by-id tools address fields by id). Bind these by119 case-insensitive match on the field alias/name (respecting the noted type):120 - `<amount_field>` — numeric: `^amount$` → `transaction_amount` → `value`121 - `<scenario_field>` — categorical: `^scenario$` → `^version$`122 - `<month_field>` — date/period: `reporting_date` → `posting_date` → `^month$` → `^date$`123 - `<account_l1_field>` — `dr_acc_l1` → `account_l1` → `account_group_l1`124 - `<account_l2_field>` — `dr_acc_l2` → `account_l2` → `account_group_l2`125126> **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 numeric `id` and its `alias` (empty if none). Address a field by alias (via the `*_by_alias` tools) when it has one, else by numeric `id` (via the `*_by_id` tools). By-id always works — never abandon the query because the aliased set is thin.127128 If `<kpis_table_id>` exists, `list_aliased_fields(<kpis_alias>)` (or129 `get_fields_by_id(<kpis_table_id>)`) and bind:130 - `<metric_name_field>` — `^metric$` → `metric_name` → `kpi_name`131 - `<quarter_field>` — `^quarter$` → `quarter` → the KPI table's date field132 - `<kpi_value_field>` — numeric: `^value$` → `^amount$`133134 If `<amount_field>` or `<scenario_field>` has no clear match, ask the user135 which field to use, then continue.1361373. Find the account category values needed for the P&L and Balance Sheet138 sections: `start_distinct_values_by_alias(<financials_alias>, <account_l1_field>)`139 (or `start_distinct_values_by_id(<financials_table_id>, <account_l1_field_id>)`)140 → poll the matching `get_distinct_values_result_by_alias`/`_by_id(handle)`141 until ready (async-fetch pattern).142 If the distinct call errors, fall back to143 `get_data_by_alias(<financials_alias>, select=[<account_l1_field>], limit=500)`144 (or the by-id twin) and collect the distinct values. Match:145 - `<revenue_value>` ← `/revenue|sales|income/i`146 - `<cogs_value>` ← `/cogs|cost of goods|cost of sales|direct cost/i`147 - `<opex_value>` ← `/operating|opex|expense|sg&a/i`148 - balance-sheet categories ← `/asset|liabilit|equity/i`149150 If a category has several candidates, pick the broadest top-level one; if151 genuinely ambiguous, ask the user once. If the L1 values partition as the152 balance-sheet equation (asset / liability / equity / income-like) rather153 than P&L buckets, the P&L categories live one level deeper — pull the154 `<account_l2_field>` distinct values and match `<revenue_value>` /155 `<cogs_value>` / `<opex_value>` there instead (see the data-scope156 discovery below).157158Aggregation-field failures are handled reactively, not pre-probed (see Step 3).159160> **Async fetch — aggregations and distinct values run as start → poll.** `start_aggregation_by_id`/`_by_alias` and `start_distinct_values_by_id`/`_by_alias` take 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 that `handle` back verbatim to the matching `get_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, pass `limit` to the result tool). An expired/unknown-handle error means restart with the `start_*` tool. *Transitional fallback:* if the `start_*` tools aren't available on the connector (older server), the blocking twins `get_aggregated_data_by_*` / `get_distinct_values_by_*` still work with the same arguments.161162> **Data-scope discovery — run before any aggregate (reuse anything already discovered this conversation).**163> 1. **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 (`Budget` frequently 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.164> 2. **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.165> 3. **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.**166> 4. **Reading GROUP BY responses.** Each response returns **exactly one row per requested group** — no subtotal rows and no grand-total row mixed into the `data` list; grand totals arrive in a separate top-level `totals` field beside the rows (`{"data": [...], "totals": {...}}`), computed across **all** groups, not just the returned prefix. **For a grand total, read `totals` — never sum the rows when the response carries `truncated: true`** (summing the returned prefix silently under-counts; dev repro: 474 of 31,455 rows summed to 21% of the true total). **`totals` combines the per-group results rather than re-scanning the rows**, so it is exact exactly when the aggregation is decomposable: SUM (sum of the group sums), COUNT (sum of the group counts), MIN, and MAX. It is **WRONG for AVG** (unweighted mean of the group averages) and **COUNT_UNIQUE** (sum of the per-group distinct counts, so a value recurring across groups is counted once per group) — true average = SUM total ÷ COUNT total (two calls: a field may be aggregated at most once per request); true distinct count = the distinct-values tools. Treat every aggregation type not named exact above — **`UNIQUE_VALUES` included**, whose cross-group de-duplication is unverified (the `COUNT_UNIQUE` behaviour above is evidence the engine may not de-duplicate across groups at all) — as not decomposable: derive it from complete rows or the distinct-values tools, never from `totals`. `totals` is absent on dimension-less aggregations (the single returned row IS the total) and may be absent on responses cached before the rollout (cache TTL ≤ 7 days) — only in those two cases is a total obtained by summing complete (untruncated) rows. 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.167> 5. **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 (~50 KB). The `data` prefix is **incomplete** — never compute totals, shares, or trends from it, and never present it as the full result. On aggregations the top-level `totals` field is **unaffected by truncation** (computed across all groups, not just the returned prefix) — read grand totals from it instead of re-fetching. Narrow the query (fewer dimensions, more filters, fewer selected columns — or a business metric for a named KPI) and re-fetch **only when the rows themselves are needed** beyond the cap; with `totals` present, a SUM/COUNT/MIN/MAX grand total never requires a re-fetch or chunking by dimension (AVG, COUNT_UNIQUE and UNIQUE_VALUES never read `totals` — true average = SUM total ÷ COUNT total from two calls; true distinct count = the distinct-values tools). A truncated response **without** `totals` (pre-rollout cache) cannot answer a grand-total question from its prefix. Re-run the aggregation **once** — a fresh run may miss the stale entry and return `totals`. If the re-run still carries no `totals`, stop re-running and fall back to narrowing or chunking by dimension until the responses are complete, then sum those rows. Never total the prefix.168169### Step 3: Fetch Data via MCP170171Aggregation-first. Run in parallel where possible. Use the alias path172(`start_aggregation_by_alias`) when `<financials_alias>` exists; otherwise the173by-id twin (`start_aggregation_by_id`).1741751. **Monthly P&L** — `start_aggregation_by_alias(<financials_alias>,176 dimensions=[<account_l1_field>, <account_l2_field>, <month_field>],177 metrics=[{"field": <amount_field>, "agg": "SUM"}], filters=[{"name":178 <scenario_field>, "values": [<--scenario> or <discovered actuals-like scenario>], "is_excluded":179 false}])` → poll `get_aggregation_result_by_alias(handle)` until ready180 (async-fetch pattern). First validate the scenario against the domain from the181 data-scope discovery — if it isn't there, list the scenarios that do exist182 and ask rather than running an empty extract. Scope to `--year` with an183 advanced date filter (see below) or by filtering the `<month_field>`184 dimension client-side; when `--year` wasn't given, use the latest complete185 fiscal year from the discovered date range — never an unscoped all-time186 pull.1872. **Balance Sheet items** — same table grouped by `[<account_l1_field>,188 <account_l2_field>, <month_field>]`, filtered client-side to the balance-sheet189 account categories found in Step 2.3.1903. **Quarterly KPIs** — only if `<kpis_table_id>` was found:191 `start_aggregation_by_alias(<kpis_alias>, dimensions=[<metric_name_field>,192 <quarter_field>], metrics=[{"field": <kpi_value_field>, "agg": "SUM"}], …)`193 → poll `get_aggregation_result_by_alias(handle)` until ready (async-fetch194 pattern) (or the by-id twins), for `--year` and the prior year (for YoY). For named195 KPIs that aren't in a table (e.g. ARR), call `list_business_metrics` to196 check whether the org defines them as populated metrics — but this skill's197 toolset reads KPI *values* only from tables, so a KPI found in neither the198 KPI table nor the P&L-derivable grain is **omitted** from the workbook199 (see the KPI-honesty rule under Sheets to Generate). Never back into a200 SaaS metric by aggregating the P&L.2014. **Distinct values for validation** — derive the distinct `<scenario_field>`,202 `<month_field>`, and `<account_l1_field>` values from the aggregation results,203 or run the distinct-values start→poll tools (`start_distinct_values_by_alias`204 / `start_distinct_values_by_id` → the matching result tool) directly,205 to confirm the extract covers the expected dimensions.206207**Reading the responses:** apply rule 4 of the data-scope discovery to every208aggregation payload — every row is a real group and no total row is appended209to the rows. Monthly totals, subtotals, and YoY math come from your own sum of210complete rows (never a truncated prefix — narrow and re-fetch); grand totals211for the validation sheet read from the top-level `totals` field. Treat212`[null]` groups as their own explicit213bucket.214215**Filter rules:**216- **Date ranges filter directly** via an advanced filter — no epoch workaround217 needed. To scope to `--year`, pass `{"name": <month_field>, "values": {"type":218 "advanced", "val": [{"condition": "total_range", "value": ["<jan1_epoch>",219 "<dec31_epoch>"]}]}}` (epoch seconds as strings; by-id uses the `field_id`220 form). Jan 1–Dec 31 assumes a calendar-aligned fiscal year — if the org's221 discovered fiscal calendar is offset, use its fiscal-year boundary dates222 instead. Adding `<month_field>` as a dimension and filtering by `--year`223 client-side still works and is optional.224- **Value-list filters** take `values: [...]` (set `is_excluded: true` for225 NOT-IN); advanced filters also support comparisons, ranges, text matching, and226 null checks.227228**If an aggregation call fails on a dimension field with a 500:** that field229isn't usable as a dimension for this client. Re-inspect the Step 2 schema for230a sibling account-level field from the discovered schema (orgs often carry231in-between levels, or an `account_group_l1`-style alternative)232and retry with it. If the alias call errors, retry the by-id twin. If no sibling233works, tell the user which field failed.234235Auto-refresh tokens are handled by the MCP layer; fall back to236`get_data_by_alias` / `get_data_by_id` with paging only if aggregation fails237outright. On `"truncated": true`, the returned rows are an incomplete prefix —238narrow the query per the `guidance` (more filters / fewer columns / lower239limit+offset paging) and re-fetch; never present the prefix as complete. (When240only a grand total is needed — e.g. for the validation sheet — read the241top-level `totals` field instead of re-fetching; see preamble item 5.)242243### Step 4: Build the Workbook Locally244245Generate the xlsx with openpyxl. **Do not** call any server-side workbook tool — they have been removed.246247Write a single Python script and execute it via `Bash`. The script reads a JSON payload of the extracted data and writes the xlsx.248249If openpyxl is missing:250- Claude Code: `pip install openpyxl`.251- Claude.ai web / ChatGPT: openpyxl is preinstalled in the analysis/code interpreter sandbox.252253## Sheets to Generate254255> **Render only KPIs you can source.** A KPI may come from (a) the org's metric catalog — `list_business_metrics` (ungated) for discovery; the `get_business_metric_*` data tools are feature-gated and may be absent, and USER-kind metrics often return empty — or (b) aggregation over the discovered P&L grain (revenue, expense buckets, gross/operating margin when COGS/OpEx-like buckets exist). SaaS/unit-economics metrics (ARR, MRR, churn, LTV, CAC, burn, runway, NRR) are **not** derivable from a P&L table — include them only if discovered as populated metrics; otherwise omit the card/slide entirely. Never render a placeholder, estimate, or fabricated value for a KPI you could not source.2562571. **Summary**258 - Year, scenario, generation timestamp.259 - Totals: Revenue, COGS, Gross Profit, Gross Margin %, Operating Expenses, Operating Income, Net Income — each labeled with the period + scenario it covers (e.g. "FY<year> · <scenario>"), never presented as bare all-time numbers.260 - Balance Sheet snapshot: Total Assets, Total Liabilities, Total Equity (period-end, labeled with the as-of month).261 - KPI snapshot: only KPIs actually sourced per the rule above; drop the block entirely if none were sourceable.262 - Row-by-row validation checks (see sheet 4) summarized as PASS / FAIL count.2632642. **P&L**265 - Rows: account categories discovered in Step 2.3, indented by L1/L2.266 - Columns: 12 months + Total + Prior Year + YoY Δ%.267 - Subtotals (Revenue, COGS, Gross Profit, Total OpEx, Operating Income) bolded.268 - Number format: `$#,##0` for dollars, `0.0%` for percentages.2692703. **KPIs** *(only when at least one KPI was sourced — otherwise omit the sheet)*271 - One row per **sourced** metric. Columns: Q1 / Q2 / Q3 / Q4 / FY / Prior FY / YoY Δ%.272 - Candidate SaaS metrics (ARR, Net New ARR, Gross/Net Churn %, LTV, CAC, LTV/CAC, NRR, GRR) appear only when sourced per the rule above; P&L-derivable KPIs (revenue, gross/operating margin) are always fair game. No placeholder or estimated rows or columns — omit them instead.2732744. **Validation**275 - One row per cross-check:276 - "P&L Revenue total equals KPI Revenue total" → PASS/FAIL with both values (skip when no KPI source exists).277 - "Sum of monthly Revenue equals annual Revenue" → PASS/FAIL.278 - "Cross-grain checksum" — the P&L total computed from the monthly rows matches the same window aggregated at a single grain (one call, no month dimension) → PASS/FAIL. (Replaces the old grand-total-row check: responses no longer append a total row.)279 - "All 12 months present in extract" → PASS/FAIL.280 - "Scenario coverage" — list distinct scenarios and confirm `--scenario` is among them.281 - "Discovered field coverage" — list the fields bound in Step 2 and confirm each returned data.282 - Footer: generation timestamp.283284## Datarails Brand Styling285286Apply the same brand styling block as `/dr-insights` and `/dr-intelligence`:287288**Font:** Poppins (fall back to Calibri). Weights: 400 / 600 / 700.289290**Colors:**291| Role | Hex |292|------|-----|293| Navy (header bg) | `0C142B` |294| Main text | `333333` |295| Secondary text | `6D6E6F` |296| Border | `9EA1AA` |297| Section bg (lavender) | `F2F2FB` |298| Input bg | `EAEAFF` |299| Input text (indigo) | `4646CE` |300| Favorable | `2ECC71` |301| Unfavorable | `E74C3C` |302| Validation PASS | `2ECC71` |303| Validation FAIL | `E74C3C` |304305**Layout:**306- Content starts at column B (column A narrow gutter).307- Rows 1-6 header banner: navy background, white title, white subtitle (year + scenario).308- Gridlines OFF. Freeze panes at B7.309- Footer row: generation date + "Datarails Financial Extract".310- Every cell needs font, fill, alignment, number format.311312**Number formats:** `_(* #,##0_);_(* (#,##0);_(* "-"_);_(@_)` (default), `$#,##0` (dollars), `0.0%` (percent).313314**Variance coloring:** YoY Δ% cells use green (`2ECC71`) for favorable, red (`E74C3C`) for unfavorable. For expenses, lower is favorable; for revenue/margin, higher is favorable.315316## DR.GET Formulas — Authoring Contract317318If asked to add live / refreshable Datarails formulas (DR.GET) to a generated319workbook, the only valid form is:320321```322=DR.GET(Value, "[DimensionName]", CellRef, "[DimensionName]", CellRef, ...)323```324325- **Never transliterate an MCP/API call into a formula.** DR.GET takes no326 table, field, or aggregation arguments — `=DR.GET(Value,"financials","Amount","SUM",...)`327 is invented syntax that the Datarails Add-in cannot parse or refresh.328- Dimension names go in square brackets inside quotes (`"[Scenario]"`).329 Dimension values are **always cell references**, never hardcoded strings.330- Date cells referenced by formulas hold end-of-month **date serials**331 computed from the calendar — never raw epoch timestamps from API responses332 (epochs land a day early with a time component and never match).333- Before writing any formula, create the workbook-scoped defined name `Value`334 referring to the string constant `"Value"`335 (`wb.defined_names.add(DefinedName("Value", attr_text='"Value"'))`) —336 otherwise Excel autocorrects the bare token to its built-in `VALUE()` and337 the formula breaks.338- Bare `=DR.GET(...)` only — never wrapped in IFERROR/IF/ROUND.339- **Every rule here applies to the retrieval/period family** — `DR.GET`,340 `DR.QTD`, `DR.YTD`, `DR.MTD` share one form (`=DR.QTD(Value, "[Dim]",341 CellRef, ...)`), one cell-reference discipline, one `Value` defined-name342 requirement, one no-wrapping rule. "DR.GET" in this contract means that343 family. Helper functions with their own documented signatures (e.g.344 `DR.INCLUDE`, `DR.RANGE`) are **not** covered here — author those only from345 their own documentation, never by analogy with this form.346- **In a live Excel context, writing DR formulas and refreshing them is one347 atomic step** — a freshly written DR cell reads `Missing` until an agent348 refresh lands, and only read-back values may be quoted. The Excel-context349 routing preamble (or the skill's own Step 0 workflow) owns that procedure;350 this contract owns the formula text.351352The get-formula skill (`/dr-get-formula`) is the full reference — parameter353cells, validated dimension values, report layouts. Prefer it for whole formula354workbooks; apply this contract when adding any retrieval/period DR formula355(`DR.GET`/`DR.QTD`/`DR.YTD`/`DR.MTD`) to a workbook here.356<!-- end:drget-authoring-contract -->357358## Step 5: Output359360- **Claude.ai web / ChatGPT**: present the xlsx as a downloadable artifact.361- **Claude Code**: print the absolute path.362363Always include in the summary:364- Output file path365- Year and scenario extracted (every quoted total carries this label)366- Validation result count (e.g. "5/5 PASS")367- Any warnings (missing months, scenario gaps, KPIs omitted as unsourceable)368369## Troubleshooting370371**No table matches the financials pattern (Step 2)**372- List the tables you found and ask the user which one holds their P&L /373 financial data, then continue.374375**Extract comes back empty for the requested scenario**376- Re-check the scenario domain from the data-scope discovery: the scenario377 name you filtered on may simply not exist in this org (budget-like data378 often lives in a planning-version field instead). Offer the scenarios that379 do exist.380381**Aggregation rejected on a dimension field (500)**382- Swap to a sibling field from the Step 2 schema and retry (see Step 3). If383 no sibling works, tell the user which field failed.384385**Token expires during extraction**386- The MCP layer auto-refreshes. If 401 errors persist, reconnect via Connectors UI.387388**Missing months in data**389- Check the `<month_field>` type. If the API stores year as a string, ensure the client-side `--year` comparison is against `"2025"` not `2025`.390391**openpyxl not available**392- Claude Code: `pip install openpyxl`.393- Claude.ai / ChatGPT: should be preinstalled in code-execution sandbox.394395## Related Skills396397- `/dr-tables` — Explore available tables.398- `/dr-query` — Investigate specific records.399- `/dr-intelligence` — Full 10-sheet insights workbook (this skill is the simpler 4-sheet variant).400- `/dr-insights` — Executive PowerPoint + Excel combo.