AI authoring guide

The dsdoc JSON format AI assistants use to build and edit DreamSheets documents — tables, tiles, charts, the formula language, and the edit workflow, in one reference.

This is the exact guide the MCP server's `describe_format` tool and AI → Copy Prompt for AI hand to an AI assistant. Using claude.ai? Download it as a Claude skill and upload it once in your Skills settings — Claude then knows DreamSheets in every chat.

You are writing a DreamSheets document. DreamSheets is a desktop spreadsheet built around a canvas of named tables, KPI tiles, and charts — not a cell grid. Your job: output one JSON object in the format below. The user will paste it into DreamSheets (AI → Import AI Output…), which builds the real document.

Output rules

  • Output only the JSON object, ideally in a single ```json code block. No commentary before or after.
  • Reference everything by name — never invent ids. Table and tile names share one namespace and must be unique across the whole document; column names must be unique within their table.
  • Use readable names: Filer Name, not filer_name or FilerName. Quote them in formulas ('Filer Name') and SQL ("Filer Name"). No symbols ($ % ( )) — units belong in format or description.
  • Layout is optional: omit rect and DreamSheets auto-places every card (usually best). A table, tile, or chart may carry an explicit "rect": { "x", "y", "w", "h" } when you want to control placement — it is honored exactly.
  • Omit anything you don't need. Every field except the ones marked required is optional.

The shape

{
  "title": "Q3 Budget",
  "tabs": [
    {
      "name": "Overview",
      "tables": [
        {
          "name": "Sales",
          "description": "Monthly sales by region",
          "columns": [
            { "name": "Region", "type": "text" },
            { "name": "Amount", "type": "number", "format": "currency" },
            { "name": "Units", "type": "integer" },
            { "name": "Total", "formula": "@Amount * (1 + TaxRate)" }
          ],
          "rows": [
            ["East", 1200.5, 30],
            ["West", 2200, 51]
          ],
          "totalsRow": true
        }
      ],
      "tiles": [
        { "name": "TaxRate", "value": 0.08, "format": "percent" },
        { "name": "GrandTotal", "formula": "SUM(Sales.Amount)", "format": "currency" }
      ],
      "charts": [
        {
          "name": "Sales by Region",
          "type": "bar",
          "table": "Sales",
          "x": "Region",
          "y": ["Amount", "Units"],
          "yAxisTitle": "USD"
        }
      ]
    }
  ],
  "customFunctions": [
    { "name": "Markup", "params": ["amt"], "body": "amt * 1.2" }
  ]
}

For a simple single-tab document you may skip the tabs wrapper and put tables / tiles / charts at the top level.

Tables

  • name (required), description, columns (required, at least one), rows, totalsRow, summaryRows.
  • Columns are either data columns (type, optionally format) or calculated columns (a formula instead of a type — their values are computed). - type: text | number | integer | boolean | date | timestamp. Omit it and the type is inferred from the row data (dates as "YYYY-MM-DD" strings are detected). - format (display only): "number" | "currency" | "percent" | "date" | "bool", or an object like { "kind": "currency", "symbol": "€", "decimals": 0 } (symbol defaults to $), { "kind": "number", "decimals": 2, "thousands": true, "compact": true } (compact abbreviates 2800000 → 2.8M).
  • `rows` is a 2D array aligned with the data columns only — never include cells for calculated columns. Dates go in as "2026-07-04" strings; percent values as fractions (8% = 0.08).
  • `totalsRow: true` appends a "Totals" row that SUMs every numeric column.
  • `summaryRows` pin custom footer formulas: [{ "label": "Average", "cells": { "Amount": "AVERAGE(Amount)" } }].
  • `descriptionHeight` (px) fixes the height of the card's description strip; omit it to auto-size.
  • A column may carry a `style` for column-uniform presentation: { "wrap": true }, { "bold": true, "align": "right" }, fontSize, italic, strikethrough. Text and background color are per-cell formatting, not part of this.

Query tables (sql)

A table with a `sql` key (alias: query) is a query table: its columns and rows come from a DuckDB SELECT over the document's other tables, and the SQL stays attached so the table can be refreshed when its sources change ("linked": false materializes it once as static data instead).

{
  "name": "RevenueByRegion",
  "description": "Joined + aggregated in SQL",
  "sql": "SELECT s.Region, SUM(s.Amount) AS Revenue FROM \"Sales\" s GROUP BY s.Region ORDER BY Revenue DESC",
  "columns": [{ "name": "Revenue", "format": "currency" }]
}
  • Reference tables by their exact name, double-quoted (FROM "Q1 Sales"). Calculated columns are materialized before the query runs, so SQL can read them too.
  • A tile or named value is a name in SQL too: WHERE "Date" > "Budget Month" (double quotes — '…' is text in SQL). A same-named column wins.
  • Omit columns/rows — the result defines them. A columns entry here only decorates a result column (format, description), matched by name; unknown names are ignored with a warning.
  • Query tables compile after every static table, so the sql may reference tables declared later in the document — and a query table may read another query table in the same document: declaration order doesn't matter, tables are compiled by dependency.
  • Reach for this over a formula when you want a new table: joins, GROUP BY, window functions, filtering, reshaping. Use LOOKUP / calc columns when you just want one more column on a table you already have.

dataWithheld (read-only)

An exported document may show a table with a `dataWithheld` note instead of rows — its data came from a Python/R script or an external database, and was deliberately not included. The columns are real; the values simply aren't shown to you. Reason about that table by its schema, don't invent rows for it, and never emit dataWithheld yourself. An external-database table also shows its sql and connectionId — that SQL runs on the connection, not over the document; import leaves such a table alone.

Tiles (single-value KPI cards)

  • { "name": "TaxRate", "value": 0.08 } — a literal number, text, or boolean. Other formulas can reference the tile by name.
  • { "name": "GrandTotal", "formula": "SUM(Sales.Amount)" } — a computed KPI.
  • Optional format (same as column formats) and description.
  • Optional `style` for the value's presentation: { "color": "#1e7e34" }, backgroundColor, align, fontSize, fontFamily.

Sections (titled frames)

A tab may carry `sections` — titled frames drawn behind the cards to group them visually ("Key Metrics", "Inputs"). Membership is geometric: a card belongs to whichever section's rect contains its center, so a section lists no members and a rect is required.

"sections": [
  { "title": "Key Metrics", "rect": { "x": 480, "y": 0, "w": 724, "h": 220 }, "fill": "#d4edda" }
]
  • Optional fill (hex; omit for the default faint tint), style: "solid" (default) | "dotted", and autofit: true to re-wrap the frame around its members automatically.
  • Sections are placed exactly where you say and are not collision geometry — they are supposed to overlap the cards they group. Give the cards inside explicit rects too (the same optional rect field every table/tile/chart accepts).

Charts

  • table (required): the home table's name. type: bar | line | pie | scatter | area | histogram | density | violin | map (default bar). Accepted aliases: column → bar, donut → pie, point/scatterplot → scatter, hist → histogram; the key itself may also be spelled geom or kind.
  • Channels take column names: x (categories), y (one name, or an array for multi-series), color, and for scatter size / shape. A column from another table is written "OtherTable.Column".
  • Optional: name, stacked: true (bar/area), xAxisTitle, yAxisTitle, description.

Formulas

Excel-like, but references are by name — there are no cell addresses:

  • @Column — this row's cell, and the ONLY spelling for it: "@Amount * 0.1". It needs a row, so it is valid in a calculated column or a per-cell formula and nowhere else (a tile or a summary cell has no row).
  • Column — the whole column of the same table, in EVERY context: "SUM(Amount)". A whole column in a single-value slot is #TYPE!, so per-row arithmetic needs the @: "@Amount * 2", never "Amount * 2". Mixing the two is how a share of total is written: "@Amount / SUM(Amount)".
  • Column:3 / Column[expr] — one specific cell of that column (1-based).
  • Table.Column — a column of another table: "SUM(Sales.Amount)". Self-qualifying (Sales.Amount inside Sales) means exactly the same as the bare form.
  • TileName — a tile's value: "@Amount * (1 + TaxRate)".
  • TileName.Title — the tile's name as text (follows renames): "\"See \" & TaxRate.Title".
  • Names with spaces or symbols go in single quotes — Excel's sheet-name spelling: "SUM('Q1 Sales'.'Unit Price')", "@'Unit Price' * 2", "'Grand Total' * 1.1". '…' is always a NAME and "…" is always TEXT, in every position, so there is nothing to disambiguate. Renaming a table or tile rewrites its references automatically.
  • PREVROW(Column, [default]) — the previous row's value of Column (a roll-forward / running-total primitive). On the first row it returns default, or blank (which is 0 in arithmetic) if omitted. Prefer this over an index crutch: write "PREVROW(End, 100) * 1.05", not "IF(@MonthNum=1, 100, End[@MonthNum-1]) * 1.05" — no MonthNum position column needed.
  • ROW() — in a calculated column, the current row's 1-based number within its table (1, 2, 3, …). No arguments. Use it for a row-number column ("ROW()") or a running position; unlike Excel's ROW() it means the table row, not a worksheet row.
  • RANDOM(min, max, [decimals], [seed]) — a stable random value in [min, max], rounded to decimals (default 0 = integers). Seeded by row position, so each cell is assigned once and does not change on unrelated edits or reload (not Excel's volatile RAND). Composes like any number: "RANDOM(1, 100)", "RANDOM(0, 1, 4)", "RANDOM(1, 6) + RANDOM(1, 6)". Two columns with identical arguments produce identical values — pass a distinct seed ("RANDOM(1, 100, 0, 2)") to reshuffle or to make an independent second column.
  • NORMALDISTRIBUTION(x, mean, stddev, cumulative) — normal distribution density (cumulative = FALSE) or the probability a value is ≤ x (cumulative = TRUE). `NORM.DIST` is the same function under Excel's spelling; both names work, and XLSX import/export translates either way.
  • INVERSENORM(probability, mean, stddev) — the value at which the normal CDF reaches probability (0 < probability < 1). `NORM.INV` is the same function under Excel's spelling. Compose with RANDOM to sample from a normal distribution: "INVERSENORM(RANDOM(0, 1, 6, seed), mean, stddev)".
  • T.DIST(x, deg_freedom, cumulative) / T.INV(probability, deg_freedom) — Student's t distribution and its inverse, under Excel's own names. T.INV(0.975, df) is the critical value for a two-sided 95% interval. Excel's three-segment names (T.INV.2T, T.DIST.2T, T.DIST.RT) do not parse — a function name may carry only one dot — so write T.INV(1 - alpha/2, df) instead.
  • CONFIDENCE.T(alpha, standard_dev, size) — HALF the width of the (1 - alpha) confidence interval for a mean, so a bound is `"AVERAGE(Trial.Score) + CONFIDENCE.T(0.05, STDEV.S(Trial.Score), COUNT(Trial.Score))". Prefer it over CONFIDENCE.NORM(...)`, which assumes the population standard deviation is known rather than estimated.
  • Table access (computed indices): - Table[Row, Column] — reads a cell at 1-based row and column indices (both computed at runtime). - CELLBYINDEX(Table, Row, Column) — explicit version of the above. - COLUMNBYINDEX(Table, Column) — in a calculated column, reads the current row at 1-based column index. - ROWBYINDEX(Table, Row) — in a calculated column, reads the current column at 1-based row index.
  • Operators: + - * /, ^ (power), & (text concat), comparisons = <> < > <= >=. Strings use double quotes; escape with \". - ^ is left-associative (2^3^2 is 64), and unary minus binds looser than it: -2^2 is -4 (standard math). Write (-2)^2 if you mean 4. - There is no % operator — write 0.05, not 5%. Scientific notation is supported (1e5, 1.5e-3, 2E+10).
  • Functions: SUM, AVERAGE, COUNT, MIN, MAX, COUNTIF(range, criterion), COUNTIFS(range1, criterion1, …), SUMIF(range, criterion, values), SUMIFS(sum_range, range1, criterion1, …), LOOKUP(needle, lookup_col, result_col, [default]), XLOOKUP(needle, lookup_col, result_col, [if_not_found]), IF(cond, then, else), IFERROR(value, fallback), CONCAT, LEN, TRIM, ROUND(n, digits). - XLOOKUP is the indexed lookup — on large tables an exact-match XLOOKUP over a data column is served by a prebuilt index, so prefer it for big joins. LOOKUP has the same argument order. - The plural *IFS forms AND every criterion — SUMIFS(Amount, Region, "East", Units, ">10"). Note SUMIFS's sum range comes first (unlike SUMIF). - SUMPRODUCT(array1, array2, …) multiplies equal-length arrays element-wise and sums (a weighted sum / dot product): SUMPRODUCT(Qty, Price). Only the plain multi-array form — the SUMPRODUCT((cond)*vals) array-arithmetic idiom is not supported; use SUMIFS for conditional sums.
  • Math: ABS(n), SQRT(n), MOD(n, divisor), INT(n), ROUNDUP(n, digits), ROUNDDOWN(n, digits). These follow Excel's semantics, not a programming language's: MOD's sign follows the divisor (MOD(-3, 2) is 1), INT floors (INT(-2.5) is -3), ROUNDUP goes away from zero. - Also LN(n), LOG(n, [base=10]), LOG10(n), EXP(n), POWER(n, p), PI(), SIGN(n), SUMSQ(...), TRUNC(n, [digits]) (chops toward zero, unlike INT), CEILING(n, [significance]) / FLOOR(n, [significance]) (round to a multiple, default 1), and the combinatorics FACT(n), GAMMALN(x), COMBIN(n, k), PERMUT(n, k). A log transform is "LN(@Income)"; every out-of-domain argument (LN(0), LN(-1), FACT(-1)) is an error value, never a silent NaN.
  • Statistics — Excel's names, Excel's arithmetic, so results diff cleanly against a spreadsheet or R: - Spread: STDEV.S/STDEV.P(...), VAR.S/VAR.P(...), AVEDEV(...), DEVSQ(...). The .S forms use n−1 and need two or more numbers (one value is #DIV/0!, not 0). - Centre: MEDIAN(...), MODE.SNGL(...), GEOMEAN(...), HARMEAN(...), TRIMMEAN(array, percent). - Position: PERCENTILE.INC/PERCENTILE.EXC(array, k), `QUARTILE.INC/QUARTILE.EXC(array, quart), RANK.EQ/RANK.AVG(number, ref, [order]), PERCENTRANK(array, x, [significance]), LARGE/SMALL(array, k). .INC interpolates on k(n−1), .EXC` on k(n+1) — pick the one the source you're matching used. - Shape: SKEW(...) (3+ values), KURT(...) (4+ values, excess kurtosis so a normal sample is ~0), STANDARDIZE(x, mean, stddev). - Counting: COUNTA(...) counts everything non-blank (text included); COUNTBLANK(...) counts the blanks. - Regression: SLOPE/INTERCEPT/RSQ(known_ys, known_xs), CORREL(array1, array2), STEYX(known_ys, known_xs) (residual SE on n−2 df; it carries no x, so it is the interval at the centre only — never build a prediction interval from it), FORECAST(x, known_ys, known_xs) (`FORECAST.LINEAR` is the same function), TREND(known_ys, [known_xs], [new_xs]) (simple regression only), and the DreamSheets-only TRENDEQUATION(known_ys, known_xs, type, [order]) returning the fitted equation as text (type: "linear"|"exp"|"log"|"power"|"poly"). These use the SAME fit the chart trendlines draw, so a tile computed from them always matches the chart. - Conditional: AVERAGEIF(range, criterion, [avg_range]), `AVERAGEIFS(avg_range, range1, criterion1, …), MINIFS/MAXIFS(value_range, range1, criterion1, …)` — same criterion syntax as SUMIF. MINIFS/MAXIFS return 0 when nothing matches, as in Excel. - A bare column name aggregates the whole column anywhere ("STDEV.S(Amount)"), and naming its table ("STDEV.S(Sales.Amount)") means the same thing. - Dotted names are ordinary function names here — STDEV.S(...) parses as one call.
  • Logic: AND(a, …), OR(a, …), NOT(a). These reject text — AND("hello") is an error. - IFS(cond1, value1, cond2, value2, …) returns the first true condition's value (#N/A if none — end with TRUE, fallback). SWITCH(expr, value1, result1, …, [default]) compares like = (text is case-sensitive). Prefer either over deeply nested IFs.
  • Error/type checks: ISERROR(v), ISNA(v), ISBLANK(v), ISNUMBER(v), ISTEXT(v). Use these to guard, e.g. IF(ISBLANK(@Cost), 0, @Price / @Cost). Number-like text is ISTEXT, not ISNUMBER.
  • Dates: TODAY(), NOW(), DATE(year, month, day), EDATE(serial, months), EOMONTH(serial, months) return dates; YEAR/MONTH/DAY/HOUR/MINUTE/SECOND(serial), WEEKDAY(serial, [type]), and DATEDIF(start, end, "d"|"m"|"y") return numbers. A date is a serial number (days since 1970-01-01), so date arithmetic just works: "@Date + 30" is thirty days later, "@End - @Start" is a day count. A calculated column whose formula's outermost call returns a date (the first five above) auto-formats as a date — no format needed. A column of =YEAR(…) stays a number; "@Date + 30" shows a bare serial until you set its format to "date" (root is arithmetic, not a date call), so add "format": "date" there if you want it shown as a date.
  • Time-value of money: NPV(rate, value, …), XNPV(rate, values, dates), IRR(values, [guess]), XIRR(values, dates, [guess]), PV(rate, nper, pmt, [fv], [type]), FV(rate, nper, pmt, [pv], [type]), PMT(rate, nper, pv, [fv], [type]), NPER(rate, pmt, pv, [fv], [type]), RATE(nper, pmt, pv, [fv], [type], [guess]). These follow Excel's conventions exactly, including the two that surprise people: - Sign convention: money paid out is negative, money received positive. PMT on a positive loan amount returns a negative payment: "PMT(0.05/12, 360, 200000)" is -1073.64. Flip it with a leading - if you want a positive number on the card. - `NPV` discounts the first value by one period. A t=0 outlay goes outside the call: "-1000 + NPV(0.1, Cashflows)", not "NPV(0.1, Cashflows)". IRR is the opposite — its first value is t=0, so pass the outlay inside: "IRR(Cashflows)". - type is 0 (default, payment at period end) or 1 (payment at period start). - XNPV/XIRR take a values column and a dates column of equal length and discount ACT/365 from the first date. IRR/XIRR need at least one positive and one negative flow, and return #EVAL! if they can't converge (Excel's #NUM! maps to #EVAL! here).
  • Text: LEFT(text, [n]), RIGHT(text, [n]), MID(text, start, n) (start is 1-based), SUBSTITUTE(text, old, new, [instance]) (case-sensitive; instance replaces just that occurrence), UPPER(text), LOWER(text), HYPERLINK(url, [text]) (a clickable link; the cell's VALUE is text, so everything reading the cell still sees plain text — https:/http:/mailto:/tel: only), TEXT(value, format_text) (Excel format codes: "$#,##0", "0.0%", "$#,##0.0,,\"M\"", "mmm d, yyyy"; quote literal letters with \"; no scientific/fraction/[h]/[>100] codes), FIND(find, within, [start]) (case-sensitive, literal) and SEARCH(find, within, [start]) (case-insensitive, */? wildcards) — both return a 1-based position and an ERROR when not found, so "contains" is ISNUMBER(SEARCH("east", @Region)); TEXTJOIN(delimiter, ignore_empty, values…) joins values or a whole column (TEXTJOIN(", ", TRUE, Team.Name)); VALUE(text) (rarely needed — arithmetic already reads number-like text).
  • Not available (don't emit these): VLOOKUP, INDEX/MATCH, WORKDAY/NETWORKDAYS/ YEARFRAC/WEEKNUM, and any array/spill formula (including SUMPRODUCT with inline array math like (A="x")*B). Use LOOKUP instead of VLOOKUP. For anything else here, put the logic in a query table (a table with sql — full DuckDB SQL) rather than a formula. - Criteria are Excel-style strings: COUNTIF(Region, "East"), SUMIF(Units, ">10", Amount). Text criteria take Excel wildcards: COUNTIF(Notes, "*refund*") (contains), "north*" (starts with), "<>*test*" (doesn't contain); ~* / ~? match the character itself. - Or write the condition as a comparison over a BARE column in place of any column/criterion pair: SUMIF(Amount, Units > 10), COUNTIF(Status <> "Closed"), SUMIFS(Amount, Region = "East", Units > 10). Never @Units > 10 there — that is one row's TRUE/FALSE, not a per-row test.
  • Custom functions: define once in customFunctions (alias: functions; body references the params by name), call anywhere: "Markup(@Amount)".

External data (MCP only)

  • list_connections → describe_connection(connectionId, filter?) for table/column names → create_external_query_table(connectionId, sql, name?, tab?). Derive everything else with sql tables over it. tab (a tab name) places the table; set_card_rect cannot move a card across tabs.
  • No connection yet? add_connection(kind, name, config) saves one for credential-free kinds: socrata (config is the portal or dataset URL) and csv / duckdb / sqlite (config is a file path; the user approves). Kinds that need credentials are refused — ask the user to add them under File → Database Connections.
  • Never paste fetched data as static rows: no record of its source, no refresh.
  • The table is a snapshot, never live: the user refreshes it, which re-runs your SQL.
  • Result column names come from the source's SQL, and refresh re-matches them by name — rename in a derived table, not the source table. Socrata (SoQL) allows identifier aliases only (AS filer_name).
  • A result too big for memory lands as a read-only dataset; the tool result says which.

Scripts (MCP only)

For data that is not one query (a union of two sources, cleanup code), write a Python/R script:

  • add_script(name, source, language?, outputs?) (an apply_actions action) creates the card, or replaces the source of the script with that name. It does not run.
  • run_script(script) runs it — the user approves every run. Scripts read tables as sheet.Name.to_df() / sheet.sql(...) and write with emit_table(df, name=…) / emit_tile(v, name=…).
  • A run updates only outputs the script itself is bound to; an unbound emit whose name is taken creates Name (2). To have a script refresh an existing table, bind it: outputs: { "emitName": "Table name" }. The emit name must match exactly: each run OVERWRITES a bound table it emits, and unbinds (never deletes) one it doesn't.
  • Column types follow the data frame: ints → integer, floats → number, .dt.date → date, datetime64 → timestamp.

Editing an existing document

A saved .dsheet file is a zip of JSON plus parquet: never read or write it directly. Work from the document's dsdoc export instead — in a chat, ask the user for AI → Copy Prompt for AI (this guide plus the open document as dsdoc JSON; Copy Document for AI is the JSON alone); over MCP, call get_document.

To change the document, reply with a dsdoc object containing only what you are adding or changing. It is merged into the open document — import_document (default onNameConflict: "update") over MCP, AI → Import AI Output… for a pasted reply — as one undoable step the user reviews item by item:

  • Anything you omit is left untouched: tabs, tables, tiles, charts, columns, rows.
  • A table, tile or chart whose name already exists is updated in place. Repeat its exact name and include only the new or changed columns — a new column is added, an existing calc column's formula is replaced — and rows only when the data itself changes: given rows REPLACE the table's data.
  • Keep existing names stable. A new name creates a second table; to rename, use the rename actions below, which rewrite every reference.

Over MCP, apply_actions covers the granular edits dsdoc can't express, as one undoable batch: set_cell / set_cell_range (by rowIndex in the rows you read, or rowId), replace_values (find-and-replace), rename_table / rename_column / rename_tile / rename_tab (references rewritten), delete_table / delete_column / delete_tile / delete_chart / delete_tab, set_column_format / set_column_width / set_card_rect, sort_table, add_section / delete_section, move_card (to another tab), add_script. The tool's own description lists every action with its params; get_document shows every card's rect, each section's id and each tab's scripts (pass tab, tables or summary: true to narrow a large document). When the work is done, save_document(path?) saves like File → Save — to the document's own path, or to path (a .dsheet) for a document that has never been saved.

Multi-tab documents

Give each tab a name (and optionally a description). Charts may reference tables on any tab. A document that models a business usually reads best as: an Overview tab with KPI tiles + charts, and data tables on their own tabs.

Common mistakes to avoid

  1. Including calc-column cells in rows (calc values are computed — omit them).
  2. Reusing a name: two tables (or a table and a tile) with the same name is an error.
  3. Referencing columns by letter (A, B1) — DreamSheets has no cell addresses; use names.
  4. An unquoted spaced name: SUM(Unit Price) fails — write SUM('Unit Price') (SQL: "Unit Price").
  5. Formatted strings as data: write 1200.5, not "$1,200.50" (set "format": "currency" instead).
  6. A leading = on formulas is tolerated but unnecessary.
  7. Misspelling a style key — unknown keys are reported as warnings, not applied. Both backgroundColor and background_color parse; colour does not.

Good structure = an intuitive document

  • Split distinct entities into separate tables (e.g. Orders, Products) instead of one wide table; join with LOOKUP.
  • Add a short description to every table, calculated column, and chart — DreamSheets shows them, and the document explains itself.
  • Describe every column whose name alone doesn't say what it holds: a count (what is counted), a code (its values), a derived measure (its rule in words), a value that depends on another column. {"name": "Paid Count", "description": "Transfers where this entity paid."}
  • Surface the numbers that matter as formula tiles; chart anything with a trend or comparison.