Drill down on a cell in the Datarails Excel Add-in to see underlying detail — also the skill to use when the user asks to "explain the variance", "explain this number", "what's driving this", "what's behind this cell", or break a figure into its line items. In a live Excel context (add-in agent bridge available) DR formula cells drill through the add-in's own drill-down; with an .xlsx file (Claude Code) it resolves DR.GET formulas, reads hidden "dr control" filters, queries Datarails, and validates totals; with no workbook at all, the user can paste a DR.GET formula or give structured filters (no-file mode). Self-contained — discovers the client's financials table and fields on its own, no profile or setup step required.
Drill into any cell that contains Datarails data to see the underlying line-item detail. Works with:
DR.GET/DR.QTD/DR.YTD/DR.MTD/… — any DR function)Three paths. In a live Excel context (add-in agent bridge available — Claude for Excel / an open workbook with the add-in), drilling a DR formula cell goes through the add-in's own drill-down via the agent bridge (Step 0 below) — the add-in resolves filters, dates, and totals natively. (Excel context is about the bridge being present, not whether the workbook is connected.) The MCP/openpyxl workflow (Phases 0–5) is the file fallback for Claude Code with an .xlsx file and no bridge. And with no workbook at all, no-file mode (below) takes a pasted DR.GET formula, a plain-language description, or structured filters straight from chat.
Requirements (fallback path only): the MCP/openpyxl workflow requires Python with openpyxl to read Excel formulas. If not installed, run pip install openpyxl. The fallback path runs in Claude Code (not Cowork). The Excel-context path needs no Python.
| Argument | Description | Default |
|---|---|---|
<cell-reference> | The cell to drill down on, e.g. B6 or Sheet1!B6 | Required for the Excel-context and file paths (not used in no-file mode) |
--by <fields> | Fields to break down by (e.g. Account Full,Vendor). MCP/openpyxl fallback accepts a comma-separated list (multiple dimensions). Excel-context agent pivot (drilldown_by_pivot) takes exactly one field — pass the single field as rowField; if multiple are given, use the first and tell the user the others aren't supported in one pivot. | All available fields (fallback) / first field (agent pivot) |
--file <path> | Path to the .xlsx workbook file | Ask user (omit for no-file mode: pasted formula / description / structured filters) |
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 Excel-context guard probe is not this case — that is normal detection, per Step 0, and never a reason to send the user to Connectors UI.)
Decide which path to take before opening any file.
Detect Excel context via the guard delegation point of the global Excel Context
Contract (see CLAUDE.md — do not probe agent.get_session inline).
Excel context active (guard returns excel_context: true — live workbook + add-in bridge):
Delegate DR formula cells to the add-in agent drill-down. Do NOT use the openpyxl/MCP path below.
sheetName + cellAddress) from the user's reference / current selection.DR.GET/DR.QTD/DR.YTD/DR.MTD/… — any DR function). drilldown_* acts on DR widget cells only — never pass it the address of a non-DR cell. If the referenced cell is an ordinary formula over DR precedents (e.g. =N35-H35), resolve to the precedent: one DR precedent → drill that cell (and say so); several → list them and ask which to drill. A static value or a formula with no DR precedents → tell the user drill-down needs a DR cell; stop.Drill Down 1, Pivot Drill Down 1, …) — one per drill, and they stay after the answer is delivered.Missing until refreshed, so the drilled cell — and every formula depending on
it — can go Missing / #VALUE! the moment the drill lands (observed cascading
through a whole variance column).get_cell_ranges) the current values of
the drilled cell, its dependents, and the surrounding DR block the user is looking
at — this is the baseline that makes step 7's before → after report a measurement
instead of a guess. It is one cheap read of cells already on screen.drilldown_list (sheetName, cellAddress; timeoutMs: 180000)--by / pivot) → drilldown_by_pivot (sheetName, cellAddress, rowField; optional targetTemplateId/targetTemplateName). rowField is exactly one field — if --by listed several, pass the first and tell the user a pivot drills one field at a time.{"status":"done","final":true} with data: null; that is success, not failure. Find the sheet step 3 warned about by diffing the worksheet collection before/after, and read its used range. Row 1 echoes the source cell's filter context as Source: | [<dimension>]:<member> | … — one segment per filter the cell resolved, with dimension and member names discovered from the workbook at runtime, never assumed. Use that row as the citation in place of data.sources[], and check it matches the cell you meant to drill. A pivot drill's total should equal the DR cell it came from; if it doesn't, say so rather than presenting the rows. The add-in resolves dr_control filters, dates (EOMONTH), and totals natively — do not re-derive them. Skip Phases 0–5.Missing / #VALUE!,
repair with refresh_selected_cells_ribbon scoped to the affected DR cells —
never refresh_ribbon — then read back again and confirm clean.Connection: none needed. drilldown_* works on an unconnected workbook. Do not probe or branch on isConnected, and never tell the user a drill is unavailable because the workbook is unconnected. If a drill genuinely fails, read the envelope's errorCode/errorMessage and re-check param names against the fetched manual before concluding anything — never report a drill failure you have not probed (see CLAUDE.md's Excel Context Contract). (connect_file is still required for create_dynamic_range.)
No Excel context, file provided (guard returns excel_context: false — Claude Code with --file, no bridge): use the MCP/openpyxl workflow (Phases 0–5 below).
No Excel context, no file (the user pasted a formula or described a number in chat): use no-file mode (next section) — skip Phases 0–2 entirely.
See the Excel Context Contract (CLAUDE.md) for the bridge protocol, drill-down commands, and gating.
The user has no workbook open and no .xlsx to hand over — they paste or describe what to drill. Gather the DR.GET parameters from one of three intakes:
"[DimensionName]" followed by its value). Values arrive as literals here (no cell references to resolve) — strings or numbers. Dimension names come from the client's own workbook/schema; match each parsed dimension to the fields discovered in Phase 3.Then ask whether any global add-in filters (entity / reporting-unit / department-style) were active where this number came from — there is no dr control sheet to read in this mode, so global filters can only come from the user. If they're unsure, proceed without them and note in the output that totals may differ from their Excel if global filters are active.
Continue at Phase 3 (discovery + query) and present per Phase 5. Phase 4 validation is limited here: with no cached workbook value to check against, validate only if the user states the expected total — otherwise label the result "not validated against a workbook value".
--file was provided, use that path. Otherwise ask the user for the workbook path.pip install openpyxl.Goal: Determine which DR.GET dimension filters produce the number in the target cell.
Use openpyxl with data_only=False to read the formula string from the target cell.
| Cell Content | Action |
|---|---|
Contains DR.GET | Direct DR.GET - parse dimension pairs. Go to Step 1.3. |
Other formula starting with = | Derived formula - find precedent cells, check each for DR.GET. Go to Step 1.4. |
| Static number (no formula) | Tell the user this cell has no formula. Cannot drill down. Stop. |
Extract all dimension/value pairs from the formula string. A DR.GET formula has the pattern:
=DR.GET(Value, "[Dim1]", CellRef1, "[Dim2]", CellRef2, ...)For each CellRef, resolve it to the actual value in that referenced cell. Open the workbook twice: once with data_only=True (to get cached values for referenced cells) and once with data_only=False (to get formulas).
Use this regex to parse dimension pairs from the formula string:
pattern = r'"\[([^\]]+)\]"\s*,\s*(\$?[A-Z]+\$?\d+)'For each match, group(1) is the dimension name and group(2) is the cell reference. Strip $ from the cell reference and read the cached value from the data_only=True workbook.
This produces a dict like (illustrative — dimension names come from the client's own workbook):
{
"Account Group L1": {"ref": "$A6", "value": "Revenues"},
"Scenario": {"ref": "$B$1", "value": "Actuals"},
"Reporting Date": {"ref": "B$5", "value": 46053}
}Store this as the resolved DR.GET parameters for this cell. Go to Phase 2.
If the cell contains a non-DR.GET formula (e.g. =B6+B7-B8), extract all cell references from the formula and recursively check each one.
Use regex to extract cell references: r'(?<![A-Z])(\$?[A-Z]+\$?\d+)'
For each referenced cell:
Build a tree structure where:
drget node with params (resolved dimensions) and cached_valuederived node with formula and childrenImportant: When the target is a derived formula, the drill-down will produce one breakdown table per DR.GET leaf. Inform the user which DR.GET sources contribute to the number and show the breakdown for each.
Important: When extracting cell references from SUM ranges like =SUM(C6:C9), expand the range into individual cell references: C6, C7, C8, C9. Do not treat C6:C9 as two separate references.
The Datarails Excel Add-in stores global filters in a hidden worksheet named "dr control" (or similar names like "DR_Control", "drcontrol"). These filters restrict what data DR.GET returns - the drill-down MUST account for them.
Search all sheet names for any containing "control" (case-insensitive). Common names: dr control, DR_Control, drcontrol.
The control sheet stores filter configuration as JSON strings in cells. Scan all cells for JSON content containing FilterStorageValues.
Each cell may contain a JSON array or object. Parse it and look for objects that have a FilterStorageValues key.
Each filter object has this structure (values shown are illustrative — your org's fields and values will differ):
{
"Key": "global",
"FilterStorageValues": [
{
"Id": 1234567,
"Name": "Reporting Unit",
"Type": "Text",
"Values": ["Unit A"],
"AllValues": [null, "Unit A", "Unit B", "Unit C", "Unit D"],
"IsExcluded": false,
"IncludeNullValues": true
}
]
}Parse each filter and build a list of active global filters:
| Filter Property | Meaning |
|---|---|
Name | The field/dimension being filtered |
Values | Selected values (whitelist) |
AllValues | All possible values for this field |
IsExcluded | If true, Values is an exclusion list (blacklist) |
IncludeNullValues | Whether rows with NULL in this field are included |
If Values equals AllValues (or Values is empty and IsExcluded is false), the filter is not restricting anything - skip it.
If Values is a proper subset of AllValues and IsExcluded is false, this is an active filter:
Active filter: "Reporting Unit" IN ["Unit A"]If IsExcluded is true, the filter means everything EXCEPT these values:
Active filter: "Reporting Unit" NOT IN ["Unit C", "Unit D"]Store all active filters - they must be applied to the drill-down query in Phase 3.
Now build and execute the aggregation query using the resolved DR.GET parameters + global filters from the control sheet.
This skill is self-contained: it discovers the table and the field mappings it needs inline. If you already discovered them earlier in THIS conversation, reuse them — skip to Step 3.2. 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 (may be empty). Prefer the alias
path when an alias exists — friendlier field names, far fewer tokens.
Fields. If the table has an alias, list_aliased_fields(<alias>);
otherwise get_fields_by_id(<financials_table_id>) (capture each field's
numeric id — the by-id tools address fields by id). From the fields, bind
the ones this skill uses by case-insensitive match on the alias/name
(respecting the noted type):
<amount_field> — numeric: ^amount$ → transaction_amount → value
(the metric the breakdown sums)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.
The DR.GET dimension names parsed from the workbook formula in Phase 1 are
already the client's literal field names, so use them as-is in the
aggregation filters; only fall back to schema matching if one isn't an
exact field name.
Aggregation-field failures are handled reactively, not pre-probed. If the
Step 3.3 aggregation call 500s on a dimension field, re-inspect the schema for
a sibling account-level field from the discovered schema (orgs often carry
in-between levels, or account_group_l1-style twins) and retry — this is also
a likely cause of a total mismatch in Phase 4. If an
alias call fails, fall back to the by-id twin.
If the user supplied --by <fields>:
dimensions for the aggregation query.If no --by was supplied:
Account Name/Account Full, DR_ACC_L2-style account levels, Vendor, Department L1/Department L2, Reporting Unit, Entity.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.
Combine:
filters on the aggregationfiltersdimensions on the aggregationSUM on <amount_field> (the value field discovered in Step 3.1)Build a filters list (alias path):
{"name": "<dim>", "values": ["<value>"], "is_excluded": false}{"name": "<Name>", "values": <Values>, "is_excluded": <IsExcluded>}(By-id path: use {"field_id": <int>, "values": [...]} instead.)
Aggregation rules:
Reporting Date, Reporting Month, etc.) can be filtered
directly with an advanced filter — pass an inclusive range as
{"name": "<Reporting Date>", "values": {"type": "advanced", "val": [{"condition": "total_range", "value": ["<start_epoch>", "<end_epoch>"]}]}}
(epoch seconds as strings). Alternatively, add the date as a dimension and
filter the results client-side after the response — both work.Scenario, Account Group L0, etc.) use value-list filters.Then call start_aggregation_by_alias (preferred when the table has an alias):
start_aggregation_by_alias(
alias="<financials_alias>",
dimensions=["<drill-down-field-1>", "<drill-down-field-2>"],
metrics=[{"field": "<amount_field>", "agg": "SUM"}],
filters=<combined_filters>
)→ poll get_aggregation_result_by_alias(handle) until ready (async-fetch pattern).
By-id fallback (no alias): start_aggregation_by_id(table_id="<financials_table_id>", dimensions=[<drill-down-field-id-1>, <drill-down-field-id-2>], metrics=[{"field_id": <amount_field_id>, "agg": "SUM"}], filters=<combined_filters>) → poll
get_aggregation_result_by_id(handle) until ready (async-fetch pattern).
Important: Use the aggregation start→poll tools (start_aggregation_by_* →
get_aggregation_result_by_*), not get_data_by_alias / get_data_by_id, because aggregation
has NO row limit and returns properly computed totals. The row-fetch tools cap at 500 rows and
would return incomplete data.
If the target cell was a derived formula (Phase 1, Step 1.4), run a separate aggregation for each DR.GET leaf node. Present each breakdown individually, showing how they combine per the original formula.
Example for =B6-B7 where B6 = DR.GET(Revenues) and B7 = DR.GET(COGS):
This step is CRITICAL. Never skip it.
If a result arrives with "truncated": true, the rows are an incomplete prefix, but the top-level totals field beside them is still exact for the SUM used here — computed across all groups, not just the returned prefix, so truncation does not affect it. Run the validation below against totals, never by summing truncated rows (summing the prefix silently under-counts and would fail the match against the cell). The presented breakdown, however, still needs complete rows: narrow the query per the guidance (more filters / fewer columns / lower limit+offset paging) and re-fetch before presenting; never present a truncated prefix as the complete breakdown.
After receiving the aggregation results:
Amount values in the drill-down result — read it from the top-level totals field when present (exact even under truncation); sum the rows only when totals is absent and the response is complete (dimension-less aggregations omit totals — the single row IS the total — and responses cached before the rollout may omit it). A truncated response without totals can validate nothing — re-run the aggregation once (a fresh run may return totals); if it still carries none, narrow or chunk until the rows are complete and sum those, and never validate against a prefix.| Condition | Action |
|---|---|
| Totals match (within rounding tolerance of $1) | Proceed to present results |
| Totals do NOT match | DO NOT present the drill-down. Diagnose the mismatch. |
Common causes and fixes:
| Cause | Diagnosis | Fix |
|---|---|---|
| Missing global filter | The dr control sheet has a filter you did not apply | Re-read the control sheet, check for additional filter cells or sheets |
| Wrong field mapping | A dimension name in DR.GET does not match the Datarails field name exactly | Use list_aliased_fields / get_fields_by_id to verify field names; try a sibling account-level field from the discovered schema (orgs often carry in-between levels) |
| Date format mismatch | Reporting Date serial number not matching | Verify the date value and format being sent |
| IncludeNullValues not applied | The global filter includes nulls but the query does not | Add a separate query for NULL values in that field and combine |
| Exclusion filter reversed | IsExcluded true was not handled correctly | Double-check: if excluded, the values should be in the exclusion list |
| Multiple control sheet cells | Filters are spread across multiple cells in the control sheet | Scan ALL cells, not just the first match |
If the mismatch cannot be resolved after 2 attempts:
Show a single table with the drill-down data:
Drill-down: Cell B6 = $1,234,567 (Revenues, Actuals, Jan-26)
Active global filters: Reporting Unit = "Unit A"
| Account Name | Amount |
|------------------------|-------------|
| Subscription Revenue | $1,100,000 |
| Implementation Revenue | $134,567 |
| **Total** | **$1,234,567** |
Total matches cell value.Show each component table plus how they combine:
Drill-down: Cell B8 = $900,000 (Gross Profit = Revenues - COGS)
Component 1: Revenues (Cell B6 = $1,234,567)
| Account Name | Amount |
|------------------------|-------------|
| ... | ... |
Component 2: COGS (Cell B7 = $334,567)
| Account Name | Amount |
|---------------------------|------------|
| ... | ... |
Gross Profit = $1,234,567 - $334,567 = $900,000| Error | Action |
|---|---|
| Workbook cannot be opened | Ask user to verify path; check file is not open exclusively in Excel |
| openpyxl not installed | Run pip install openpyxl and retry |
| Cell has no formula | Tell user: "This cell contains a static value (no formula). Cannot drill down." |
| No DR.GET found in formula chain | Tell user: "This cell formula does not reference any DR.GET cells. Cannot drill down." |
| No control sheet found | Warn: "No dr control sheet found. Proceeding without global filters - totals may not match if filters are applied in the add-in." |
| Table not found in Datarails | No table matched the financials pattern — list the tables found and ask the user which one holds their P&L / financial data |
| Total mismatch after retries | Report the mismatch clearly and offer partial results flagged as unvalidated |
All examples are illustrative — DR.GET dimension names, filter values, and figures come from the client's own workbook and schema; your org's will differ.
User: /dr-drilldown B6 --file budget.xlsxCell B6 contains =DR.GET(Value, "[Account Group L1]", $A6, "[Scenario]", $B$1, "[Reporting Date]", B$5)
User: /dr-drilldown B6 --by "Vendor" --file budget.xlsxSame resolution, but only breaks down by Vendor.
User: /dr-drilldown B8 --file budget.xlsxCell B8 contains =B6-B7 where B6 is DR.GET(Revenues) and B7 is DR.GET(COGS).
User: /dr-drilldown C10 --file model.xlsxCell C10 contains =SUM(C6:C9) where C6-C9 are all DR.GET cells.
3fb5a24
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.