Detect data anomalies and generate a comprehensive data-quality Excel WORKBOOK from Finance OS tables, computed over the table's ALL-TIME history (use the anomalies skill for a chat-only answer scoped to the latest fiscal year — the two baselines differ by design, so counts won't match). The MCP tools return baseline aggregates only; this skill derives findings, severity buckets, and the Data Quality Score client-side, then writes a multi-sheet workbook. Self-contained — pass --table-id to target a table directly, or it discovers the financials table on its own; no profile or setup step required.
Generate a comprehensive data-quality Excel workbook for a Finance OS table. Works with any table — no pre-configuration required.
Tool reality check: the MCP
profile_numeric_fieldsandprofile_categorical_fieldstools are thin wrappers — they return baseline aggregates only (SUM/AVG/MIN/MAX/COUNT for numerics, distinct-value samples capped at 5 fields for categoricals). There is no server-side anomaly tool. This skill computes every finding, severity bucket, and the Data Quality Score client-side. See/dr-anomaliesfor the per-category recipes; this skill consumes those same recipes and packages the result as an Excel workbook.
General-Purpose:
--table-id to target any table directly (zero discovery)| Argument | Description | Default |
|---|---|---|
--table-id <id> | Specific table to analyze (used directly, no discovery) | Discovers the financials table |
--severity <level> | Filter results: critical, high, medium, low | All |
--output <file> | Output filename | tmp/Anomaly_Report_TIMESTAMP.xlsx |
profile_numeric_fields) plus a skill-derived range-band outlier
flag. Std dev and percentiles are not returned by the API and are
not reported unless the skill bucketed the field via the aggregation
start→poll tools (start_aggregation_by_* →
get_aggregation_result_by_*) (note that in the sheet when present).profile_categorical_fields (capped at 5 fields per call), plus
per-value frequencies derived from the aggregation start→poll tools
(start_aggregation_by_* → get_aggregation_result_by_*) (nulls
appear as the explicit [null] bucket; every returned row is a real
group, so the frequency table needs no total-row exclusion).get_data_by_alias / get_data_by_id after the skill identifies
the IDs to pull.Phase 1: Discovery
id and
its alias (the alias may be empty)list_aliased_fields(<alias>)
(business-friendly aliases); otherwise get_fields_by_id(<table_id>)
(capture each field's numeric id — the by-id tools need ids). Prefer the
alias path when an alias exists.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.
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.
Resolve the target table:
If --table-id <id> was passed: use it directly as <table_id>. No
discovery needed — the user named the table. This works for any table,
financial or not; skip straight to loading its fields. (Don't try to
name-match or look for category values that may not exist on an arbitrary
table.) Resolve its alias via list_data_models if you want the alias path.
Otherwise (default / no-arg path): discover the financials table inline. If you already discovered it earlier in THIS conversation, reuse it.
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 its numeric id and its alias.list_aliased_fields(<alias>) (if aliased) or
get_fields_by_id(<table_id>). The anomaly analysis is field-agnostic —
it profiles whatever numeric and categorical fields the schema exposes —
so no semantic field binding is required here. When per-value frequency,
null, or duplicate detection needs a grouping dimension, take the
categorical fields straight from this schema.start_distinct_values_by_alias(<alias>, <account_field>) (or
start_distinct_values_by_id(<table_id>, <account_field_id>)) → poll
the matching get_distinct_values_result_by_*(handle) until ready
(async-fetch pattern). If the distinct call errors, fall back to
get_data_by_alias(<alias>, select=[<account_field>], limit=500) (or the
by-id twin) and dedupe.Aggregation-field failures are handled reactively, not pre-probed: if an
aggregation start→poll call (start_aggregation_by_* →
get_aggregation_result_by_*) 500s on a dimension field, re-inspect the
schema for a sibling and retry; if the alias call fails, fall back to the
by-id twin; if none works, tell the user which field failed.
Phase 2: Gather baseline aggregates
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.
Period: this skill deliberately overrides item 3 above. Its baseline is
ALL-TIME by design — a data-quality scan covers the whole table, because
the future-dated-rows check and out-of-range detection only work if the
queries can see rows outside the expected window. (Filtering to the
discovered range would in any case be a no-op: the range is derived from
the data's own MIN/MAX, so it already contains every row — including the
future-dated ones the check exists to find.) The period rule in item 3
governs financial reporting; this workbook is a data-quality artifact.
Consequence: its outlier counts, null rates, and severity-bucket sizes will
not match /dr-anomalies, which scopes the same recipes to the latest
complete fiscal year — state the baseline in the workbook so the two are
never read as the same measurement, and never let the Numeric Analysis
SUM/AVG columns be read as period P&L figures.
Scenario: NOT exempt — discover it and split on it. The period override above does not extend to scenario, and an unscoped multi-scenario scan produces false findings rather than broader ones:
Actuals and
Budget is the table working correctly, not a duplicate. Cross-scenario
pairs must never be reported as duplicate rows.MAX - MIN and can both mask real actuals
outliers and flag ordinary forecast values.So in Phase 1, discover the scenario domain the same way /dr-anomalies
does — pull distinct values of the scenario-like field
(start_distinct_values_by_alias/_by_id → poll the matching result tool);
never assume a scenario name exists. Then either add the scenario field as a
grouping dimension to the duplicate / rare-value / null-rate aggregates and
report findings per scenario, or scope the scan to the actuals-like
scenario and say so. Either way the workbook names the scenarios it covers —
which it cannot do honestly without discovering them first.
profile_numeric_fields(table_id) — full numeric coverage
(SUM/AVG/MIN/MAX/COUNT per numeric field). Treat the result as a
starting point, not as classified findings.profile_categorical_fields(table_id, fields=[...]) — pass an
explicit field list (tool silently caps at 5 per call; loop as
needed to cover them all).start_aggregation_by_alias (preferred) or
start_aggregation_by_id grouped by the relevant dimension(s) with
COUNT of a different dense field as the metric — never COUNT
the grouped dimension itself (see "Reading GROUP BY responses"
below) — by-alias metrics=[{"field": <other_field_alias>, "agg": "COUNT"}], by-id metrics=[{"field_id": <other_field_id>, "agg": "COUNT"}] → poll the matching get_aggregation_result_by_alias /
get_aggregation_result_by_id with the handle until ready
(async-fetch pattern). This is where the actual findings come
from — the profile tools alone can't produce them.get_data_by_alias /
get_data_by_id. You can filter directly with an advanced condition
tree — e.g. comparison {"name": <amount_alias>, "values": {"type": "advanced", "val": [{"condition": "gt", "value": "<band>"}]}} to pull outlier rows, or a value-list IN of the
offending IDs ({"name": <id_alias>, "values": [...]}). Comparisons,
ranges, and is null are all supported — no need to pre-identify IDs
purely because the filter API can't express a comparison.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.
Phase 2b: Derive findings (client-side)
First normalize each GROUP BY response: keep only rows in which every
requested dimension key is present (data-scope preamble, item 4), preserving
genuine [null] values — during the stale-cache window this drops legacy
roll-up rows that would otherwise inflate the null-rate denominator and the
per-value frequency shares. Then apply the recipes from /dr-anomalies
(range-band outliers, null rates, duplicates, rare-category values,
future-dated rows) to the aggregates from Phase 2 — but over this skill's all-time baseline,
not the fiscal-year window the recipes are specified for in
/dr-anomalies (the borrowed recipes carry their window with them;
the counts will differ from a /dr-anomalies run by design). When tabulating a
GROUP BY response, every row is a real group — no total row is appended — so
the total-row-count denominator is your own sum of all group counts
(including [null]). Null rate = the [null]-bucket count ÷ that summed
total. Bucket by severity using the
heuristics in that skill. Drop categories the API can't support
(referential integrity, character-level hygiene).
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.
Phase 3: Report Generation
/dr-anomalies (the skill, not the tool).Phase 4: Summary
/dr-query with an IN list")./dr-anomalies-reportOutput:
🔍 Discovering financials table...
✓ Found financials table: TABLE_ID
📊 Analyzing table TABLE_ID...
📈 Profiling numeric fields (SUM/AVG/MIN/MAX/COUNT)...
📝 Profiling categorical fields (distinct + samples)...
📊 Aggregating per-value counts for duplicate / null detection...
🧮 Computing findings + severity buckets client-side...
🔍 Fetching sample records for top findings...
📄 Generating Excel report...
✅ Report generated: tmp/Anomaly_Report_2026-02-03_143022.xlsx
==================================================
ANOMALY DETECTION SUMMARY
==================================================
Table: TABLE_ID
Total Anomalies: 45
Data Quality Score: 87/100
By Severity:
Critical: 2
High: 8
Medium: 23
Low: 12
Report: tmp/Anomaly_Report_2026-02-03_143022.xlsx
==================================================/dr-anomalies-report --table-id TABLE_ID --severity critical/dr-anomalies-report --env app --output tmp/Quality_Check_Feb_2026.xlsxScore ranges from 0-100:
Calculation:
Score = 100 - (critical×10 + high×5 + medium×2 + low×0.5)
Clamped to 0-100 range--table-id--table-id)Whichever path resolved the table:
list_aliased_fields if aliased, else
get_fields_by_id)start_distinct_values_by_* →
get_distinct_values_result_by_*) (fall back to
sampling rows with get_data_by_alias / get_data_by_id only if the
distinct call errors)/dr-anomalies-report --env app --output tmp/DQ_Check_$(date +%Y-%m).xlsx/dr-anomalies-report --severity criticalAlerts on critical issues that could affect close
/dr-anomalies-report --table-id 12345 --severity highChecks specific department data for issues
/dr-anomalies-report --table-id unknown_table_idDiscovers what's in an unfamiliar table
Reports are saved to: tmp/Anomaly_Report_YYYY-MM-DD_HHMMSS.xlsx
Each report includes:
"Not authenticated" error
"No tables found" error
"Table not found" error
--table-id value is correct/dr-tables to see available tablesNo table matches the financials pattern (default path)
--table-id <id>./dr-tables - List and explore available tables/dr-extract - Extract validated financial data/dr-reconcile - Compare P&L vs KPI dataScaling handled automatically via pagination and efficient MCP tools.
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.