Finicast for agents
Finicast is a calculation engine for models made of line items × time periods: forecasts, budgets, plans, financial statements, sales-ops frameworks. You declare tables, a pivot, and rules of the formtarget[condition] = expression; the engine evaluates the grid, recomputes only what an edit changed (milliseconds at 100,000 rows), and renders markdown. A human can then open the model at a URL, change an input, and see every dependent cell update. The engine is finidb, an npm package that runs locally with no account; the hosted service exposes the same tools over MCP.
What Finicast does
- Rules, not cells.
revenue[frame=fcst] = PREV(revenue) * (1 + growth) covers every forecast period; a 40-line statement is a dozen rules. - Three statements that reference each other, in separate pivots, across periods (interest on last year's debt, cash from this year's cash flow), resolved cell by cell.
- Same-period circularity (interest on average debt, a minimum-cash revolver): set
"iterate": true and the engine iterates to a fixed point like Excel's iterative calculation. Details. - Dashboards the user steers: an editable drivers table, KPIs, charts, pivot-style tables with a commentary column, navigation across dashboards, and section headings over the parts of a long page, and a theme for the audience (research, banking, revenue ops, the executive board page, FP&A, boardroom, print), declared in the same document. Details.
- Excel export with live formulas compiled from the rules: one sheet per statement, inputs blue, formulas black,
SUMIFS over ledgers, iterative calculation carried over. Run it yourself with finidb build model.json --xlsx model.xlsx; the download on finicast.com is switched off for now. Details. - Ledgers roll up live. Load 100,000 rows, write
SUM(activities.score), and an edit to any row recalculates in under a millisecond. - A link the user can open, edit and share, whether you reached finicast.com or built locally. Explain any cell: its rule and precedents.
If you cannot reach finicast.com, deliver the model anyway; never ask the user to install a connector first. Do not guess what your sandbox can reach: try npm first, then fall back.
- Run
npx -y finidb build model.json. npm is reachable from most sandboxes (Claude.ai, Claude Code, Cursor) even when finicast.com is not. It prints the statements, a link of the form https://finicast.com/import#m=…, and writes model.xlsx, the workbook with live formulas. Put the link in your reply verbatim and attach the workbook; the link opens the model in one click. Add the JSON as a code block after them, as a fallback. - Only if that install fails (ChatGPT: your code tool has no network at all, and your interface truncates long links, so no link either): reply with the model document as a JSON code block plus the sentence Open https://finicast.com/import and paste this reply. The user pastes your whole reply; the page finds the JSON and builds the workspace with the dashboard you declared.
Both are complete deliverables, but the first gives the user a one-click link and a workbook, so take the thirty seconds to try it. Mention the connector only at the end, as the way to skip this next time.
Describing the result
The user gets three things from one document: a live model to steer (the link), a workbook they can audit, restyle and extend (the .xlsx, with live formulas compiled from the same rules), and the statements. Say so. If you compare with a spreadsheet or a script, compare fairly: the workbook is the spreadsheet, produced without a single hand-written cell formula, and the rules are the audit trail. Name the drivers and invite the user to change them.
Deliver a model in this turn: take the first path that works
The model document is the deliverable. Whatever your tools can reach, one of these produces a live, editable model for the user now. Do not stop to ask for a connector; that is a footnote, not a prerequisite.
- Finicast tools are connected (MCP). Use them:
finicast_create_model, finicast_set_rules, finicast_query, finicast_share. - You can POST to finicast.com.
POST https://finicast.com/api/build with the document below; the response is the statements plus the workspace link. - You can only GET.
GET https://finicast.com/api/build?spec64=<base64url of the JSON> returns the same. - Otherwise, no route to finicast.com. Try
npx -y finidb build model.json first and put the printed #m= link in the reply with the workbook attached, JSON block after them. Only if the install fails (ChatGPT): the JSON block plus Open https://finicast.com/import and paste this reply, and no link. Details in the note above.Making a link by hand, when you can run code and your client keeps links under 2,000 characters intact (ChatGPT does not):
import json, base64
b = base64.urlsafe_b64encode(json.dumps(doc).encode()).decode().rstrip("=")
print("https://finicast.com/import#j=" + b) # or raw deflate first (zlib.compressobj(9, zlib.DEFLATED, -15)) as #m=
Path 4 is a complete deliverable, not a fallback. Mention the connector (below) only at the end, as the way to skip the paste next time.
The prompts on the home page, and what each one means
finicast.com offers these prompts with a Use prompt button that opens Claude or ChatGPT with the text filled in. When one of them arrives, this is what the user expects: start from the named recipe, keep its shape, and change the subject.
Company forecast
Use Finicast (https://finicast.com/for-agents) to build a five-year forecast for CoreWeave
Build: An editable assumptions pivot (the drivers by year), an operating model with hist and fcst frames, a funding or cash pivot, and one dashboard: KPI tiles for the last forecast year, the drivers table with editable: true, revenue and margin charts. Take the history from public filings you know; state the sources and the frozen date in the reply.
Start from Three-statement forecast · theme research · reply with the link, the workbook and the drivers to try.
Comps for a ticker
Use Finicast (https://finicast.com/for-agents) to build a comparable companies analysis for NVDA against its closest peers: EV/Revenue, EV/EBITDA and P/E on LTM and NTM, revenue growth and EBITDA margin, and the implied share price.
Build: Pick six to eight peers yourself and say why, then research the inputs: price, shares, cash, debt, LTM and NTM revenue, EBITDA and EPS for every company, with the as-of date and sources in the reply. Assume the user has no data feed. Build the full comps shape (see Standard of work below): a companies table with a peer flag, an editable market_data pivot, a company × line pivot with the researched LTM and NTM figures as inputs beside EV and the multiples derived by rules, a peer-statistics pivot (low, mean, median, high), an implied-valuation pivot that applies each statistic to the subject with the upside to the current price, and a forward projection. A brief table of hand-typed multiples is not a comps analysis. Dashboard theme banking, with tables for financial performance, valuation and multiples, peer statistics and implied value.
Start from Comparable companies · theme banking · reply with the link, the workbook and the drivers to try.
Sales ops
Use Finicast (https://finicast.com/for-agents) to build a sales ops model for a $50M ARR SaaS company: pipeline by stage and segment, quota attainment by rep, commissions with accelerators and kickers, and dashboards for the CRO and each rep.
Build: No data was given, so generate it: reps, accounts and a few hundred opportunities with realistic spread (lognormal deal sizes, a power-law over accounts, stage-dependent win rates), never uniform noise. Monthly pivots by rep, quarterly roll-ups through period.quarter, an editable comp plan table with tranches, and commission = in_tranche × quota × base rate × multiplier. One dashboard per persona, each starting with a links card and cut into named sections; page the rep dashboard by rep, and drill from the company charts into a region, a segment, a quarter and a rep, passing the member id and its label so the detail page reads as a name.
Start from Sales operations · theme executive · reply with the link, the workbook and the drivers to try.
Budget vs actual
Use Finicast (https://finicast.com/for-agents) to build a budget vs actual model from my general ledger: actual, budget, variance and variance % by department, line and month, with a commentary column and a controller dashboard. I will paste the ledger CSV next.
Build: Wait for the CSV; if it does not come, build with a small sample ledger and say so. Put the ledger in a table with csv and a period = PERIOD(date, periods) rule, roll it into a department × line × month pivot with versions as a dimension (actual, budget, variance, variance_pct), budget as inputs, and a text measure for commentary shown as an extra column. Map the ledger accounts to lines explicitly and show the mapping in the reply.
Start from Budget vs actual with commentary · theme controller · reply with the link, the workbook and the drivers to try.
Standard of work: what a finished model contains
The user compares what you build with the demos on finicast.com, which were built to the standard of a bank or an FP&A team. Match that depth. A model that is a handful of typed-in numbers with one formula is a spreadsheet, not a model. The rules that apply to every build:
- A dashboard that scrolls is a document. Give it section headings every four to six cards, a range on any chart whose axis would otherwise be a smear of periods, and a drill from the summary into the detail behind it. A reader who cannot see where the page changes subject, or what a bar is made of, is reading a wall.
- Every dashboard wears a theme. Set
"theme" on each dashboard: banking for comps, precedents, a DCF or any valuation; research for a company forecast or an earnings model; revenue for sales, pipeline, quota and commission work; executive for the page a CRO or a GM takes into a board or QBR; controller for budgets, variance, close and ledger reporting; boardroom for a screen at the front of a room. If you leave it out, finicast.com picks one from the model's contents, but choose it yourself: you know the audience. - A theme of your own. When the user has colours of their own — their brand, their firm's house style — start from the closest of ours and say what differs:
"theme": { "base": "banking", "vars": { "--accent": "#7a1f2b", "--bg": "#f7f3ee", "--theme-head-bg": "#7a1f2b", "--theme-rule": "#c8a23a", "--chart-palette": "#7a1f2b,#c8a23a,#3c5a7a,#6b7a5a" } }. Everything you leave out keeps the base theme's value, so an accent, a background and a palette are usually the whole job. The properties you may set: --bg, --surface, --fg, --muted, --faint, --line, --stripe, --accent, --accent-fg, --accent-soft, --positive, --negative, --theme-head-bg and --theme-head-fg (card title bars), --theme-rule (the line under them), --theme-thead-bg and --theme-band (table headers and banding), --theme-input (figures the reader may type into), --radius, --font, --theme-display (headings) --chart-palette (two to twelve series colours, comma separated), and — for a map — --chart-ramp (the stops a choropleth shades between, light to dark) and --chart-ramp-diverging (negative, middle, positive). Colours are hex or rgb()/hsl(), never a name; a value that is not of that kind, or that tries to fetch anything, is dropped and the base's value stands. Ask for the colours rather than guessing them, and keep the contrast honest: light text on a light background is not a brand, it is an unreadable dashboard. - Inputs live in editable pivots, results in rules. Anything the user might change (a multiple, a growth rate, a margin, a share count) is an input on an editable table card. Anything derived (EV, a multiple, an implied price, a margin) is a rule, never a typed number. The point of the deliverable is that the user moves a driver and everything follows.
- Breadth over brevity. Where the recipe has statistics, sensitivities and an implied-value bridge, so must your model. Say in the reply what the drivers are and invite the user to change them.
A valuation or comparable-companies ask
Whatever the phrasing ("valuation analysis for Intel", "comps for NVDA", "where should X trade"), the deliverable has these parts; the comparables recipe has all of them working:
- The data is your job. Assume the user has no data feed. Research the inputs yourself: share price, shares outstanding, cash and total debt from the latest balance sheet, revenue, EBITDA and EPS for the last twelve months and for the year before (growth), and consensus revenue, EBITDA and EPS for the next twelve months, for the subject and for six to eight peers you chose. Use filings, investor-relations pages and the financial sites you can read. Never leave an input blank and never invent a number you could not find: if a figure is unavailable, say so in the reply and leave that peer out. State the as-of date and the sources in the reply, put the date in the model's name and the market-data card title, and tell the user every input is editable so they can correct or refresh it. Prefer US-listed peers; an ADR quotes in dollars but reports in its home currency.
- a
companies table: the subject plus the peers, with ticker, group and a peer flag (0 for the subject), so statistics exclude the subject; market_data: an editable company × line pivot ("dims": { "company": "companies", "period": false }) with price, shares outstanding, cash and debt as inputs;comps: a company × line pivot with the researched LTM and NTM figures as inputs (revenue, EBITDA, EPS, plus the prior-year revenue) and rules for market cap, net debt, EV, growth, margins, EV/Revenue, EV/EBITDA and P/E on both bases. Multiples are rules, never typed in;peer_stats: a stat × line pivot over the peers (MIN, AVG, MEDIAN, MAX with [company.peer=1]), and medians by group when the peer set has groups. Give it "lineTable": "comps_lines" to share every line of comps, or its own lines naming a subset with the same ids: a rule reading comps.value aligns on the line of the same id either way;implied: each statistic applied to the subject's own metrics, from EV to equity value to price per share, and the upside to the current price;- a forward projection of the subject (revenue, EBITDA, margin by year, growth as an editable driver) when the ask mentions a forecast or forward numbers;
- Say how each line is written (formats): per-share figures and implied prices as
"$#,##0.00", the figures in millions as "#,##0", multiples as multiple (28.9x), growth and margins as percent. Then the names carry no units — EV / EBITDA LTM, not EV / EBITDA LTM (x) — because the numbers do. - one dashboard, theme
banking: market data (editable), financial performance LTM and NTM (editable), valuation and multiples, peer statistics, implied valuation, and a chart of EV/EBITDA by company. Companies go down the rows and metrics across on the company tables ("rows": ["company"], "cols": ["line"]; finicast does this by default for a dimension named company or one whose table has a ticker), while the statistics and implied-value tables keep lines on rows with the statistics across.
The skeleton. It is a complete document: three companies with figures as of 2026-09-21 stand in for your researched set of seven to nine. comps_lines is the line table finicast creates for the comps pivot:
{
"model": "intc_comps", "name": "Intel comparable companies (figures as of 2026-09-21)", "units": "USD millions except per share",
"periods": { "start": "2024-01", "count": 5, "grain": "year", "histUntil": "2025-12-31" },
"tables": {
"companies": { "rows": [ { "id": "intc", "name": "Intel", "ticker": "INTC", "group": "target", "peer": 0 },
{ "id": "amd", "name": "AMD", "ticker": "AMD", "group": "compute", "peer": 1 },
{ "id": "nvda", "name": "NVIDIA", "ticker": "NVDA", "group": "compute", "peer": 1 } ] },
"stats": { "rows": [ { "id": "low" }, { "id": "mean" }, { "id": "median" }, { "id": "high" } ] }
},
"pivots": {
"market_data": { "dims": { "company": "companies", "period": false },
"lines": [{ "id": "price", "name": "Share price ($)" }, { "id": "shares", "name": "Shares outstanding (M)" }, { "id": "cash", "name": "Cash and investments" }, { "id": "debt", "name": "Total debt" }],
"inputs": { "price": { "intc": 108.6, "amd": 559.8, "nvda": 222.3 }, "shares": { "intc": 5044, "amd": 1631, "nvda": 24217 },
"cash": { "intc": 29727, "amd": 12000, "nvda": 69000 }, "debt": { "intc": 50537, "amd": 3165, "nvda": 8491 } } },
"comps": { "dims": { "company": "companies", "period": false },
"lines": [{ "id": "market_cap", "name": "Market cap" }, { "id": "net_debt", "name": "Net debt (cash)" }, { "id": "ev", "name": "Enterprise value" },
{ "id": "revenue_prior", "name": "Revenue, year before LTM" }, { "id": "revenue_ltm", "name": "Revenue LTM" }, { "id": "revenue_ntm", "name": "Revenue NTM" },
{ "id": "ebitda_ltm", "name": "EBITDA LTM" }, { "id": "ebitda_ntm", "name": "EBITDA NTM" }, { "id": "eps_ltm", "name": "EPS LTM ($)" }, { "id": "eps_ntm", "name": "EPS NTM ($)" },
{ "id": "rev_growth_ltm", "name": "Revenue growth LTM", "format": "percent" }, { "id": "rev_growth_ntm", "name": "Revenue growth NTM", "format": "percent" },
{ "id": "ebitda_margin_ltm", "name": "EBITDA margin LTM", "format": "percent" }, { "id": "ebitda_margin_ntm", "name": "EBITDA margin NTM", "format": "percent" },
{ "id": "ev_revenue_ltm", "name": "EV / Revenue LTM" }, { "id": "ev_revenue_ntm", "name": "EV / Revenue NTM" }, { "id": "ev_ebitda_ltm", "name": "EV / EBITDA LTM" }, { "id": "ev_ebitda_ntm", "name": "EV / EBITDA NTM" }, { "id": "pe_ltm", "name": "P / E LTM" }, { "id": "pe_ntm", "name": "P / E NTM" }],
"inputs": { "revenue_prior": { "intc": 53100, "amd": 29609, "nvda": 165200 }, "revenue_ltm": { "intc": 57032, "amd": 41305, "nvda": 302969 }, "revenue_ntm": { "intc": 62959, "amd": 50928, "nvda": 408908 },
"ebitda_ltm": { "intc": 3672, "amd": 10718, "nvda": 233667 }, "ebitda_ntm": { "intc": 21636, "amd": 16134, "nvda": 251724 },
"eps_ltm": { "intc": -2.11, "amd": 3.9, "nvda": 7.91 }, "eps_ntm": { "intc": 1.52, "amd": 7.63, "nvda": 9.26 } },
"rules": ["market_cap = market_data.price * market_data.shares", "net_debt = market_data.debt - market_data.cash", "ev = market_cap + net_debt",
"rev_growth_ltm = revenue_ltm / revenue_prior - 1", "rev_growth_ntm = revenue_ntm / revenue_ltm - 1",
"ebitda_margin_ltm = ebitda_ltm / revenue_ltm", "ebitda_margin_ntm = ebitda_ntm / revenue_ntm",
"ev_revenue_ltm = ev / revenue_ltm", "ev_revenue_ntm = ev / revenue_ntm", "ev_ebitda_ltm = ev / ebitda_ltm", "ev_ebitda_ntm = ev / ebitda_ntm",
"pe_ltm = market_data.price / eps_ltm", "pe_ntm = market_data.price / eps_ntm"] },
"peer_stats": { "dims": { "stat": "stats", "period": false }, "lineTable": "comps_lines",
"rules": ["value[stat=low] = MIN(comps.value[company.peer=1])", "value[stat=mean] = AVG(comps.value[company.peer=1])",
"value[stat=median] = MEDIAN(comps.value[company.peer=1])", "value[stat=high] = MAX(comps.value[company.peer=1])"] },
"implied": { "dims": { "stat": "stats", "period": false },
"lines": [{ "id": "price_ev_revenue_ntm", "name": "Price at peer EV / Revenue NTM ($)" }, { "id": "price_ev_ebitda_ltm", "name": "Price at peer EV / EBITDA LTM ($)" }, { "id": "price_ev_ebitda_ntm", "name": "Price at peer EV / EBITDA NTM ($)" }, { "id": "price_pe_ntm", "name": "Price at peer P / E NTM ($)" }, { "id": "upside_ev_ebitda_ntm", "name": "Upside at peer EV / EBITDA NTM", "format": "percent" }],
"rules": ["price_ev_revenue_ntm = (peer_stats.value[line=ev_revenue_ntm] * comps.revenue_ntm[company=intc] - comps.net_debt[company=intc]) / market_data.shares[company=intc]",
"price_ev_ebitda_ltm = (peer_stats.value[line=ev_ebitda_ltm] * comps.ebitda_ltm[company=intc] - comps.net_debt[company=intc]) / market_data.shares[company=intc]",
"price_ev_ebitda_ntm = (peer_stats.value[line=ev_ebitda_ntm] * comps.ebitda_ntm[company=intc] - comps.net_debt[company=intc]) / market_data.shares[company=intc]",
"price_pe_ntm = peer_stats.value[line=pe_ntm] * comps.eps_ntm[company=intc]",
"upside_ev_ebitda_ntm = price_ev_ebitda_ntm / market_data.price[company=intc] - 1"] },
"forecast": { "lines": ["revenue", "ebitda", { "id": "ebitda_margin", "format": "percent" }, { "id": "growth", "format": "percent" }, { "id": "margin_target", "format": "percent" }],
"inputs": { "revenue": { "fy2024": 53101, "fy2025": 52853 }, "ebitda": { "fy2024": 11200, "fy2025": 14622 }, "growth": { "fy2026": 0.19, "fy2027": 0.14, "fy2028": 0.15 }, "margin_target": { "fy2026": 0.36, "fy2027": 0.40, "fy2028": 0.42 } },
"rules": ["revenue[frame=fcst] = PREV(revenue) * (1 + growth)", "ebitda[frame=fcst] = revenue * margin_target", "ebitda_margin = ebitda / revenue"] }
},
"outputs": [ { "pivot": "comps", "rows": ["company"], "cols": ["line"] }, { "pivot": "peer_stats" }, { "pivot": "implied" }, { "pivot": "forecast" } ],
"dashboards": [ { "id": "overview", "name": "Intel comparable companies", "theme": "banking", "cards": [
{ "kind": "table", "pivot": "market_data", "title": "Market data as of 2026-09-21 (edit any cell)", "rows": ["company"], "cols": ["line"], "editable": true },
{ "kind": "table", "pivot": "comps", "title": "Financial performance, LTM and NTM (edit any input)", "rows": ["company"], "cols": ["line"], "lines": ["revenue_ltm", "revenue_ntm", "rev_growth_ltm", "rev_growth_ntm", "ebitda_ltm", "ebitda_ntm", "ebitda_margin_ltm", "ebitda_margin_ntm", "eps_ltm", "eps_ntm"], "editable": true },
{ "kind": "table", "pivot": "comps", "title": "Valuation and trading multiples", "rows": ["company"], "cols": ["line"], "lines": ["market_cap", "net_debt", "ev", "ev_revenue_ltm", "ev_revenue_ntm", "ev_ebitda_ltm", "ev_ebitda_ntm", "pe_ltm", "pe_ntm"] },
{ "kind": "table", "pivot": "peer_stats", "title": "Peer statistics (Intel excluded)", "lines": ["rev_growth_ltm", "rev_growth_ntm", "ebitda_margin_ltm", "ebitda_margin_ntm", "ev_revenue_ltm", "ev_revenue_ntm", "ev_ebitda_ltm", "ev_ebitda_ntm", "pe_ltm", "pe_ntm"] },
{ "kind": "table", "pivot": "implied", "title": "Implied Intel valuation" },
{ "kind": "chart", "type": "bar", "pivot": "comps", "lines": ["ev_ebitda_ltm", "ev_ebitda_ntm"], "title": "EV / EBITDA by company" },
{ "kind": "table", "pivot": "forecast", "title": "Forward projection (edit growth and margin)", "editable": true } ] } ]
}
Only if the user says they have a Financial Modeling Prep key set under Integrations, replace the researched inputs with a linked table that fetches them and can be refreshed: the fmp preset's comps dataset returns one row per ticker with price, shares, cash, debt, LTM and NTM figures and a peer flag from subject, and that table becomes the company dimension. The changes to the skeleton:
"tables": { "peers": { "source": { "preset": { "id": "fmp", "params": { "symbols": "INTC,AMD,NVDA,AVGO,QCOM,MU,MRVL", "dataset": "comps", "subject": "INTC" } } } }, "stats": { … } },
"pivots": { "comps": { "dims": { "company": "peers", "period": false },
"rules": ["price = company.price", "shares = company.shares", "cash = company.cash", "debt = company.debt", "revenue_prior = company.revenue_prior", "revenue_ltm = company.revenue_ltm",
"revenue_ntm = company.revenue_ntm", "ebitda_ltm = company.ebitda_ltm", "ebitda_ntm = company.ebitda_ntm", "eps_ltm = company.eps_ltm", "eps_ntm = company.eps_ntm", … the same derived rules …] },
"implied": … "comps.ebitda_ntm[company=INTC]" (the members are the tickers) … }
Do not use a linked table when you are unsure the user has a key: the build then reports SOURCE_NO_SECRET and the model is empty. Researched inputs always work.
Recipes: what to build for the common asks
Each is a complete model document you can read, run (npx finidb build examples/dcf.json from the npm package, or fetch the file from this site) and adapt. Every one builds without a cell error and exports to a workbook of live formulas; the test suite checks them on every release.
Editable operating drivers by year, a WACC build-up pivot without a period dimension ("dims": { "period": false }: risk-free rate, beta, equity risk premium, cost of debt, capital structure), unlevered free cash flow with mid-year discounting from the period index, a valuation pivot with a method dimension (perpetuity growth and exit multiple side by side, each with the implied cross-check), the bridge to equity value and price per share, and two sensitivity tables as pivots over case tables whose delta attributes shift WACC, terminal growth or the exit multiple. The dashboard wears the banking theme.
discount_factor[frame=fcst] = 1 / (1 + wacc.wacc) ^ (n - 0.5)
terminal_value[method=perpetuity] = fcf.ufcf[period=last] * (1 + wacc.terminal_growth) / (wacc.wacc - wacc.terminal_growth)
terminal_value[method=exit_multiple] = fcf.ebitda[period=last] * wacc.exit_multiple
per_share = ((NPV(wacc.wacc + wacc_case.delta, fcf.ufcf[period.frame=fcst]) * (1 + wacc.wacc + wacc_case.delta) ^ 0.5 + …) - wacc.net_debt) / wacc.shares (wacc_case × growth_case)
Market data as an editable pivot; one company × line pivot holds the financial inputs with LTM and NTM as separate lines (revenue_ltm, revenue_ntm) beside the multiples derived from them, so a single table reads Revenue LTM | Revenue NTM | … | EV/EBITDA LTM | EV/EBITDA NTM; a statistics pivot takes low, mean, median and high over peers and medians per peer group; an implied-valuation pivot applies each statistic to the subject and shows the upside to the current price. The dashboard wears the banking theme.
ev = market_data.price * market_data.shares + market_data.debt - market_data.cash
ev_ebitda_ntm = ev / ebitda_ntm
rev_growth_ntm = revenue_ntm / revenue_ltm - 1
value[stat=median] = MEDIAN(comps.value[company.peer=1]) (per line)
price_pe_ntm = peer_stats.value[line=pe_ntm] * comps.eps_ntm[company=aapl]
The same shape over a deals table (acquirer, target, date, type, consideration, EV, target revenue and EBITDA, premiums): a deal × line pivot computes the multiples and carries the terms as a text measure shown as an extra column; a statistics pivot takes low, mean, median and high over the set and medians over subsets by attribute (MEDIAN(multiples.value[deal.type=sponsor]), [deal.year >= 2025]); an implied-valuation pivot applies every statistic to an editable subject block, from EV to equity value to price per share and premium. The dashboard wears the banking theme.
ev_ebitda = ev / ebitda (per deal)
value[stat=median_sponsor] = MEDIAN(multiples.value[deal.type=sponsor]) (per line)
price_from_ebitda = (summary.value[line=ev_ebitda] * subject.ebitda - subject.net_debt) / subject.shares (per stat)
A $55M ARR SaaS sales org: 2,000 opportunities with lognormal deal sizes, accounts, reps, SDRs, a marketing table (spend, leads, MQLs) and churn events. Monthly pivots roll into quarterly ones through the period's quarter attribute; a rep × quarter × tranche pivot pays commission with accelerators from an editable plan; kickers for new logos and multi-year terms and a quarterly SPIFF add on; teams, regions, segments and the company roll up; an ARR bridge closes the loop. Six dashboards in the revenue theme, each starting with a links row: CRO, VP Sales, sales ops, a rep, SDRs and marketing.
bookings = SUM(opps.acv[rep=@rep, close_period=@period, won=1]) (rep × month)
bookings = SUM(rep_monthly.bookings[period.quarter=@quarter.label]) (rep × quarter)
in_tranche = IF(attainment <= comp_plan.from_pct, 0, IF(attainment >= comp_plan.to_pct, comp_plan.to_pct - comp_plan.from_pct, attainment - comp_plan.from_pct))
commission = in_tranche * rep_quarterly.quota * rep.base_rate * comp_plan.multiplier
spiff = COUNT(opps.acv[rep=@rep, won=1, security_addon=1, close_quarter=@quarter]) * quarter.spiff_active * plan.spiff_amount
bookings = SUM(rep_quarterly.bookings[rep.region=@region, rep.segment=@segment]) (team)
A general ledger with a date column becomes actuals by subsidiary × department × line × month through PERIOD(date, periods); budget is input per cell; versions are a dimension (actual, budget, variance, variance_pct); manager commentary is a text measure on the same pivot, shown as an extra column of the March table beside actual, budget, variance and variance %, editable in place. The dashboard wears the controller theme.
"measures": ["value", { "id": "comment", "type": "text" }]
value[version=actual] = SUM(ledger.amount[subsidiary=@subsidiary, department=@department, account=@line])
value[version=variance] = value[version=actual] - value[version=budget]
A general ledger as a CSV in the document (two entities, six departments, twelve accounts, January to August) becomes a monthly operating plan: actual months from the ledger through PERIOD(date, periods), forecast months from editable drivers (revenue growth, cost of revenue as a share of revenue, headcount × loaded cost for salaries, benefits, commissions, marketing and cloud as shares, other opex growth), a budget beside the outlook, year-to-date and full-year pivots by entity, department and account, and a P&L roll-up. Three dashboards in the controller theme: FY outlook, departments and headcount, drivers and ledger.
"ledger": { "csv": "id,date,subsidiary,department,account,amount
…", "fields": { "period": "ref:periods*" }, "rules": "period = PERIOD(date, periods)" }
value[frame=hist] = SUM(ledger.amount[subsidiary=@subsidiary, department=@department, account=@line])
salaries[frame=fcst] = headcount.salary_cost
cloud[frame=fcst, department=engineering] = -revenue[department=company] * plan.cloud_pct
value[version=ytd_actual] = SUM(model.value[period.frame=hist]) (entity × department × account × version)
opex_engineering = SUM(model.value[department=engineering, line != total]) (P&L)
Daily closes as a data table with period = PERIOD(date, periods) and a month_end flag on the last trading day of each month, so the month-end close is SUM(prices.close[period=@period, month_end=1]) (a LAST over the month would read the same but has no Excel formula); high, low and average by MAX, MIN and AVG over [period=@period]; a ceos table whose start and end reference periods, so a tenure pivot reads stock.close[period=@ceo.start] and counts months as ceo.end.idx - ceo.start.idx; a line chart with an event line per appointment and spans for the interim periods, and a drill from any month to a second dashboard whose data card is filtered to that month's daily closes. The dashboard wears the research theme.
close = SUM(prices.close[period=@period, month_end=1])
start_price = stock.close[period=@ceo.start]
annualized = (end_price / start_price) ^ (12 / months) - 1
"annotations": [{ "kind": "line", "at": "feb21", "label": "Pat Gelsinger", "color": "accent" }, { "kind": "span", "from": "dec24", "to": "mar25", "label": "Interim co-CEOs" }]
"drill": { "dashboard": "daily", "params": { "month": "$col" } }
A distribution card is a histogram of one numeric field of a data table: the observations are the rows of its cut, the bars are bins of values and the height is how many observations fall in each. Deal sizes, cycle lengths, discounts, multiples in a comps set, account balances. Two shapes: bins as a number gives equal-width value ranges on a value axis (add marks to draw the quartile, quintile or decile boundaries over them), and bins as "quartiles" | "quintiles" | "deciles" groups the observations into equal-sized buckets, each labelled with the range it covers. Skewed money fields (deal size, market cap) read far better as quantiles; a long right tail on a value-range histogram is folded into a final "and above" bar unless you set "tail": "keep". y makes the bar the sum or mean of the field instead of the count, which turns a decile chart into "how much of the money sits in each tenth". A drill passes the clicked bin: $range (from..to, which a data card's where reads as a numeric range), $from, $to, $bin and $field, so a click opens the rows behind that bar. The sales operations model has both shapes on the VP page.
{ "kind": "distribution", "table": "opps", "field": "cycle_days", "where": { "won": "1" }, "bins": 14, "marks": "quartiles" }
{ "kind": "distribution", "table": "opps", "field": "acv", "where": { "won": "1" }, "bins": "deciles",
"drill": { "dashboard": "deals", "params": { "range": "$range", "bin": "$bin" } } }
{ "kind": "data", "table": "opps", "where": { "won": "1", "acv": "$range" }, "sort": "-acv" } // the target
When things happened
A timeline card is one row per record of a data table, a dot at each dated milestone and a bar between them. Two milestones is a term — a bond from issue to maturity, a CEO's tenure, a contract — and three or more is a sequence, where the gaps are the thing worth seeing: signal to inflection to target, first touch to demo to close, filed to approved to launched. Milestones are the dated fields of the table, named in order, each optionally with a label and a color; label on the card names the field that titles each row. A date is read whichever way the table holds it — a date field, text holding 2024-03-15 or 2024-03 or 2024, or a reference to a period whose start is the date — and a milestone the card could not read is reported under it (first_signal_date on 6 of 151) rather than drawn at the epoch. align names one milestone and puts the axis in time elapsed from it, so every row's turn sits at 0 and the run-up is negative: that is the chart for how far ahead could we have known, and the lead time reads straight off it. Rows come from the card's where, sort and limit like a data card's, and the first 25 are drawn unless limit says otherwise (200 at most, because more is a smear). A drill passes the row: $id and $<field> for any field of it.
{ "kind": "timeline", "table": "bonds", "label": "name", "sort": "matures", "limit": 30,
"milestones": [{ "field": "issued" }, { "field": "matures", "label": "Maturity", "color": "negative" }] }
{ "kind": "timeline", "table": "stocks", "label": "ticker", "where": { "hit": "1" }, "sort": "-peak_mult",
"milestones": [{ "field": "first_signal", "color": "muted" }, { "field": "knee" }, { "field": "hit", "color": "positive" }],
"align": "knee", // the axis becomes -7m … +13m around each row's own turn
"drill": { "dashboard": "ticker", "params": { "ticker": "$ticker" } } }
Scenarios are a dimension, not copies: one rule set, drivers per scenario, history shared, outputs and dashboard cards paged by scenario, and a sensitivity pivot reading one period across all scenarios. Format below.
Also in the package: the three-statement CoreWeave forecast with a dashboard. Name collisions to avoid: a line item and a table with the same id (bookings) make bookings.acv read as the line; give the line a different id.
Tables that record who did what. Put "track": true on a data table and the engine keeps four columns on every row: added_by, added_at, changed_by and changed_at. It fills them from whoever is making the change, and it ignores those columns when a client sends them, so they cannot be typed. added_by and changed_by reference a people table the engine keeps for you, so they are dimensions like any other: entered = COUNT(trades.value[added_by=@person, period=@period]) charts contributions per person per month, and noted = PERIOD(changed_at, periods) turns the stamp into a period. Use it for anything a team fills in together: a trade blotter, variance commentary from department managers, notes from sales reps. Behind the columns, every change ever made is in the workspace's log, including the ones since overwritten, at GET /db/<workspace>/history?table=<table>. Attribution is only as good as the identity behind the edit, so a workspace that matters should be owned and shared with named people rather than left open to its link.
When a rebuild takes a while. A build renders a statement for every pivot unless you say otherwise, and rendering one means computing it: on a model with a hundred thousand rows that is tens of seconds each. Two things follow. Send "outputs": [] when you are updating a model rather than presenting it, and read back only what you need afterwards; the reply still carries the workspace and the url. And if a call comes back as a gateway error or a timeout, do not send it again: the build almost certainly landed, and a second attempt repeats the same work while the first is still finishing. Read the model back with finicast_schema or a query, see what is there, and send only what is missing. A build that runs long now stops rendering statements at forty seconds, keeps the model it built, and says in its log how many it left out.
Editing a model after you built it, and models a user hands you
A build reply carries workspace. Keep it: send the document again with "workspace": "<id>" and Finicast updates the model in place: rules and cards replaced, inputs rewritten, new lines added to existing pivots, rows upserted by id, new tables and pivots added. Nothing is deleted by a rebuild; drop a rule or a row through the REST routes. Dimensions of an existing pivot cannot change: give a reshaped pivot a new id. A workspace nobody has saved accepts edits from anyone with its id, until it is published: a published model reads publicly and changes only for its owner, its editors, or an agent holding a token for it.
The key that comes back with a build. A build you make without an account answers with a token as well as the workspace: keep it and send it as Authorization: Bearer fc_ws_… on every later request, and you can go on changing what you built even after the user saves the model to their account or publishes it. It opens that one workspace and nothing else, the user sees it under Share and can revoke it, and it is theirs to know: tell them the model has an agent key rather than printing the token into a document or a dashboard.
Agent tokens. When the user says something like "use Finicast with token fc_…" or pastes a prompt from the Share menu, send it on every request as Authorization: Bearer fc_… (the hosted MCP endpoint also takes it in its URL: https://finicast.com/api/mcp?token=fc_…). A fc_ws_ token edits the one workspace in the user's prompt and nothing else; always pass that workspace id. A fc_pat_ token acts as the user: models you build are saved to their account and every model they own or edit is open to you. Never print a token back, never put it in a document, and if a call answers TOKEN_SCOPE or 403, say which workspace the token covers instead of retrying elsewhere.
Keep a table in sync from your side
When the data lives in a system you control, push it: POST {workspace url}/t/{table}/import (multipart file, CSV or .xlsx) or the engine route POST /api/db/db/{workspace}/tables/{table}/load?mode=replace|upsert|append with a text/csv body. replace makes the file the table, upsert updates rows by id and adds the rest, ?dryRun=1 plans without writing. A cron job, a Zapier step or a script keeps the table current and every pivot that reads it follows. Locally: finidb load db table file.csv --mode replace.
Send a model, get the statements back
You are the modeller; Finicast is the calculator. Gather the history, decide the assumptions, write the rules, and send one JSON document to POST https://finicast.com/api/build (or GET /api/build?spec64=<base64url JSON> if you can only GET). Finicast builds the model, calculates every period, and returns the statements you asked for as markdown (or JSON with "format": "json") plus a link the user can open. Send the returned workspace back to update the same model on the next turn.
{
"model": "crwv", "name": "CoreWeave 5-year forecast", "units": "USD millions",
"periods": { "start": "2022-01", "count": 9, "grain": "year", "histUntil": "2025-12-31" }, // members fy2022…fy2030; frame = hist | fcst
"pivots": {
"assumptions": {
"lines": [{ "id": "revenue_growth", "format": "percent" }, { "id": "gross_margin", "format": "percent" }],
"inputs": { "revenue_growth": { "fy2026": 0.6, "fy2027": 0.45 }, "gross_margin": { "fy2026": 0.73, "fy2027": 0.74 } }
},
"income_statement": {
"lines": ["revenue", "cogs", "gross_profit", { "id": "gross_margin_pct", "name": "Gross margin", "format": "percent" }, "net_income"],
"inputs": { "revenue": { "fy2024": 1915.4, "fy2025": 5131 }, "cogs": { "fy2024": 493.4, "fy2025": 1453 } }, // history as inputs
"rules": [
"revenue[frame=fcst] = PREV(revenue) * (1 + assumptions.revenue_growth)",
"cogs[frame=fcst] = revenue * (1 - assumptions.gross_margin)",
"gross_profit = revenue - cogs",
"gross_margin_pct = gross_profit / revenue"
]
},
"cash_flow": { "lines": ["net_income", "capex", "free_cash_flow"], "rules": ["net_income = income_statement.net_income", "…"] },
"balance_sheet": { "lines": ["cash", "debt"], "rules": ["cash[frame=fcst] = PREV(cash) + cash_flow.free_cash_flow", "…"] }
},
"outputs": [
{ "pivot": "income_statement", "title": "Income statement ($M)", "lines": ["revenue", "gross_profit", "gross_margin_pct", "net_income"] },
{ "pivot": "cash_flow" }, { "pivot": "balance_sheet", "format": "json" }
]
}
If you cannot reach finicast.com from your sandbox: see the note at the top: the finidb build link when npm is reachable, otherwise the JSON and the paste sentence.
The document, field by field:
periods: generated time dimension shared by every pivot. Member ids are fy2026 (year), q1_2026 (quarter), jan26 (month); histUntil sets the attribute frame to hist up to that date and fcst after, which is what [frame=fcst] conditions use.pivots.<id>.lines: the line items (ids, optional name, format such as percent, and any other attribute). Each pivot is line items × periods with one measure value; add other dimensions with dims: { "region": "regions" } over a table you sent in tables.inputs: values by line and period ({ "revenue": { "fy2024": 1915 } }), the natural place for reported history; on a pivot without periods ("period": false) the inner key is the member of its other dimension, so a comps pivot takes { "price": { "intc": 108.6, "amd": 559.8 } }. Inputs beat rules, so history can be inputs while rules only cover [frame=fcst] and derived lines. Any number of periods; any units, say which in units.rules: one per line, target[condition] = expression. Cross-pivot references are other_pivot.line (income_statement.net_income); previous period is PREV(x) or x[period-1]; [frame=fcst] scopes to forecast periods. Statements may live in separate pivots and reference each other across periods (interest on last year's debt, cash from this year's cash flow); the engine resolves that recurrence cell by cell. A same-period circularity (interest on average debt when debt depends on interest, a revolver that keeps cash above a minimum) is #CYCLE by default; add "iterate": true to the document and the engine iterates it to a fixed point like Excel's iterative calculation (100 passes, tolerance 0.001; or { "maxIterations": 200, "tolerance": 0.0001 }). A cycle that will not settle returns #ITER. A rule that does not compile fails the whole build with { error: { code, message, step, fix } } so you can correct it and resend; no partial model is left behind in a new workspace.tables: optional tabular data (a ledger, a rep list) as { fields: { col: "number" | "text" | "date" | "ref:<table>" }, rows: [...] } or csv; aggregate it with SUM(ledger.amount[account=revenue, period=@period]). Row keys you did not declare become fields (text, or number when every value is a number), so { id, name, group } rows need no fields.- Linked tables (only when the user says they have the provider's key set under Integrations; by default research the figures and type them in): a table with a
source instead of rows is fetched from an API when the model is built and refreshed on demand by the user (a Refresh button; POST {workspace url}/t/{table}/refresh). The rows are a snapshot with a fetched-at stamp; nothing changes until someone refreshes. The first preset is Financial Modeling Prep: "nvda": { "source": { "preset": { "id": "fmp", "params": { "symbols": "NVDA", "dataset": "financials", "period": "annual", "limit": "10", "scale": "1000000" } } } } gives one row per fiscal year (id = NVDA_2026) with fiscal_year, date, revenue, cogs, gross_profit, opex, operating_income, ebitda, da, net_income, eps_diluted, shares_diluted, cash, total_debt, net_debt, equity, cfo, capex, fcf and more, in millions. Datasets: comps (one row per ticker with price, shares, cash, debt, LTM and NTM revenue, EBITDA and EPS, and a peer flag from subject: what a comparable-companies table needs, see the standard), financials, income, balance, cashflow, metrics (EV, EV/EBITDA, returns), ratios (margins, P/E, P/S), estimates (consensus revenue, EBITDA, EPS by fiscal year: the NTM inputs for comps), profile and quote (price, market cap, beta; id = the ticker). Several tickers in symbols fill one table with a symbol column, which is how a comps table is fed: "peers_fin": { "source": { "preset": { "id": "fmp", "params": { "symbols": "NVDA,AMD,AVGO,MRVL", "dataset": "financials", "limit": "2" } } } }. A rule reads it like any table: revenue[frame=hist] = nvda.revenue[fiscal_year=@period.year] (match by fiscal year; NVDA's FY2026 ended January 2026) or revenue_ltm = peers_fin.revenue[symbol=@company.ticker, fiscal_year=2025]. A custom source is { "url": "https://…?key={{secret:name}}", "path": "data.items", "id": "{code}", "map": { "revenue": "rev" }, "scale": 1000000 }: https only, JSON or CSV, secrets by name. Keys: the fetch runs with the key the user saved under Integrations (their account menu; the fmp preset needs the secret named fmp). Never put a key in the document. If the build reports SOURCE_NO_SECRET, the table exists but is empty: tell the user to add their FMP key under Integrations and press Refresh on the table; do not retry the build. outputs: which statements to return: pivot, optional lines subset, rows/cols/pages layout, format markdown (default) | json | both, scale, decimals, title. Omit for every pivot with its default layout.name on a pivot or table: what the sidebar shows ("Operating model"); without it the id is title-cased. Give real names; the user reads them, not the ids.iterate: true or { "maxIterations", "tolerance" } turns on iterative calculation for same-period circularities. The finance idiom, a minimum-cash revolver with interest on the average balance:interest[frame=fcst] = rate * (PREV(debt) + debt) / 2
borrow[frame=fcst] = MAX(0, min_cash - (PREV(cash) + ebitda - interest - capex))
debt[frame=fcst] = PREV(debt) + borrow
cash[frame=fcst] = PREV(cash) + ebitda - interest - capex + borrow
borrow needs interest, interest needs debt, debt needs borrow: with iterate on, the engine converges the loop; without it the cells are #CYCLE. Keep the loop damped (rates well below 100%, a MAX(0, …) floor) or it ends in #ITER.- Scenarios are a dimension, not copies of the model. Add a
scenarios table and give the driver and statement pivots a "dims": { "scenario": "scenarios" }; the same rules then run per scenario, history flows in through a plain pivot (revenue[frame=hist] = history.revenue), and drivers are set per scenario with values:"tables": { "scenarios": { "rows": [{ "id": "base" }, { "id": "bear" }, { "id": "bull" }] } },
"pivots": {
"history": { "lines": ["revenue"], "inputs": { "revenue": { "fy2025": 5131 } } },
"assumptions": { "lines": [{ "id": "growth", "format": "percent" }], "dims": { "scenario": "scenarios" },
"values": [{ "at": { "line": "growth", "scenario": "bear", "period": "fy2026" }, "value": 0.10 }, "…"] },
"income_statement": { "lines": ["revenue", "…"], "dims": { "scenario": "scenarios" },
"rules": "revenue[frame=hist] = history.revenue
revenue[frame=fcst] = PREV(revenue) * (1 + assumptions.growth)" }
},
"outputs": [{ "pivot": "income_statement", "pages": { "scenario": "bear" }, "title": "Bear case" }, { "pivot": "income_statement", "pages": { "scenario": "base" } }]
Dashboard cards take pages too, so a dashboard can show the cases side by side. For a sensitivity, vary one driver across scenarios and read one output line across them. dashboards: what the user sees first. Give one with an editable table of the assumptions and charts of the outputs, so the user changes a driver and watches the forecast move: [{ "id": "overview", "name": "…", "theme": "research", "cards": [{ "kind": "table", "pivot": "assumptions", "editable": true }, { "kind": "kpi", "pivot": "income_statement", "line": "revenue", "unit": "$M" }, { "kind": "chart", "type": "line", "pivot": "income_statement", "lines": ["revenue", "net_income"], "unit": "$M" }] }]. With a dashboard, the returned url opens it. "dashboards": "auto" derives one from the input-only pivots and the outputs.- Card kinds.
text (markdown in a card: { "kind": "text", "title": "How to read this", "text": "## Outlook
Actuals through **August** from the ledger…" }; headings, lists, bold, links and tables render, a blank line starts a paragraph and a single newline is a line break, so an address or a list of assumptions written one per line comes out that way, $param fills in a dashboard parameter, so a detail page can say Entries for $account, and a cell reference pulls a value from the model: {{profiles.strategy[company=$company]}} reads one field of the first matching row of a data table, {{stock.close[period=$month]}} one pivot cell ({{tenure.total_return[ceo=tan]|percent}} formats it). Long write-ups (a strategy, a profile) belong in a table the user can edit, with one text card reading them. Use one at the top of a dashboard for the commentary a reader needs: what the model is, its as-of date, the drivers to try, the caveats. You do not have to size it: the height is worked out from how the text wraps at the card's width, and an h you give that would cut the text off is raised to one that shows it, with a note saying so. A card the reader has resized smaller fades at the bottom edge while there is more to scroll to); section (a heading, not a card: { "kind": "section", "title": "Pipeline and coverage", "note": "Q3 2026" } takes a row of the grid but wears none of a card's chrome — no surface, no border, no shadow — so the run of cards under it reads as a part of a document rather than as more cards. It starts its own row, and each theme says it in its own voice. Put one every four to six cards on a page that scrolls, and name what the section is about, not what kind of cards are in it); data (rows of a data table: table, fields, where, sort, limit; see drill-downs below; whoever may edit the workspace also gets + Add row and can double-click a cell to change it; a cell holding an http or https address is drawn as a link, so a sources column is one, and nothing else is ever made clickable); form (a row added from the dashboard: { "kind": "form", "table": "trades", "form": { "fields": ["date", "ticker", "side", "quantity", "price"], "button": "Log the trade" } }; whatever the card's where decides arrives filled and locked, so a form on a page parameterised by department and month asks only for the note; references become pickers, computed columns and a tracked table's own columns are never offered, and the row records who added it); distribution (a histogram of one numeric field of a data table, cut by where: bins is a count of equal-width value ranges or "quartiles" | "quintiles" | "deciles", marks draws quantile boundaries over a value-range histogram, y makes each bar the count of observations, or the sum or mean of the field; see the recipe above); timeline (one row per record of a data table, a dot at each dated milestones field and a bar between them: { "kind": "timeline", "table": "bonds", "label": "name", "milestones": ["issued", "matures"] }. Two milestones is a term, three or more a sequence whose gaps are the point; align names one of them and puts the axis in time elapsed from it, so every row's own turn sits at zero. Dates are read as day numbers, as ISO text to the day, month or year, or through a reference to a period; see the recipe above); table (optional lines; editable lets the user type into input cells; hideZeroRows leaves out rows that are all blank or zero, such as accounts a department never books; sort is the order the card opens in, naming a column (a line id, or the members joined by / when several dimensions are on the columns) or label for the row header, with - first for descending, e.g. "sort": "-return_since" for a scorecard that opens best-first; a reader can then sort by any column from a control that appears on header hover, within each outer row group, and their sort rides in the URL as <cardId>.sort=-<column>, so a sorted view is a link and a link can open a table any way you like; rows and cols choose the dimensions, and a table with two row dimensions renders like a pivot, one column per dimension with the outer label written once; extra appends columns of another measure at one pinned point, e.g. "extra": [{ "measure": "comment", "label": "Commentary", "pages": { "version": "variance" } }] puts a text commentary column beside actual, budget and variance, editable in place). chart (lines, optional type line | bar | stackedBar | area | waterfall | map).
A map shades one number on a geography: { "kind": "chart", "type": "map", "pivot": "gov_bs", "rows": ["country"], "cols": ["line"], "lines": ["gross_debt_pct"], "map": { "atlas": "world" } }. Put the places on rows and the one line being shaded on cols — a shape holds one number, so a map of twelve quarters is twelve maps. atlas is world (173 countries) or us-states (the fifty states and the District, Alaska and Hawaii as insets). Shapes are met by a code, never by a name: the world carries iso3 (default), iso2 and iso_num; the states carry usps (default) and fips. Your member ids are matched against that property, case and punctuation ignored, so a countries table keyed usa, chn, deu needs nothing further. When the ids are something else, name the column that holds the code: "map": { "atlas": "us-states", "attribute": "code" }. A member that meets no shape is named under the map rather than left as a hole, so an aggregate row like a euro-area memo announces itself. "diverging": true shades a number with a meaningful zero — a surplus, a change — from the theme's negative through to its positive. Clicking a shape drills like any other point. Shade a rate, not a level: a choropleth of absolute magnitudes is a population map. kpi (line; shows the last period and the change against the one before; periods or filters pick which).
The periods a card is drawn over. periods is a list of ids, or a range over the model's own period order: "periods": { "from": "jan21", "to": "dec23" }, either end optional and an end naming no member left open. Any ordered dimension takes the same shape through filters: "filters": { "quarter": { "from": "q1_2026" } }. Prefer a range to a list of twelve ids — it says what you meant and survives the model gaining a period — and give one to any chart whose axis would otherwise run to a smear. Where the pivot declares a timeDim, the reader gets a control beside the card title to move the range without changing the card. links (a row of the model's dashboards as the document's own table of contents: names on a rule, the open one marked, no card around it. dashboards lists their ids, omit it for all. It never wraps — past the width it scrolls, with the open page brought into view — so a long list costs one line, not four. Put one first on every dashboard: a published page has no sidebar, and this is the only way a reader reaches the rest of the model). - Log scale.
"log": true on a chart puts the value axis on a log scale, so a move from 3× to 11× does not flatten the years before it. Every plotted value must be above zero; a chart with a zero or a negative in it says so instead of drawing something untrue. - Chart convention. A map is chosen, never inferred — say
"type": "map" — and it earns its place when there are enough shapes that position carries information and the geography is part of the story; for ten countries a ranked bar reads better. Otherwise: bars for amounts and flows (revenue, EBITDA, capex, cash flow, debt), lines for rates and ratios (margins, growth, leverage), stacked bars for composition, waterfall for a bridge. Leave type out and Finicast applies that rule from each line's format; a card that mixes percentages with amounts on one axis is rejected with a fix. - Bare cards.
"bare": true on any card drops the card around it — no surface, no border, no shadow, no side padding — so its contents sit on the page the way a section heading does. For prose, a list or a row of links that is commentary rather than a figure. A card carrying numbers usually wants its surface; a paragraph explaining them usually does not. - Layout. Automatic, in reading order;
w/h in a 12-column grid override it. Tables default to full width, charts to half, KPIs to a quarter, and a section heading to a full-width band one row tall that starts its own row. - The card a preview shows. A workspace is shown elsewhere — the home page, the gallery, a list of models — as one live card of its first dashboard. Say which:
"preview": true on a chart, distribution, table, kpi, data or timeline card. A map makes a good one: it reads at tile size where a chart of two hundred categories does not, and it is never discounted for having many shapes. Mark the card that carries the page, not the prettiest one; text, section, links and form cards are chrome and are never previewed, and if you mark two the first wins. Leave it out and the pick is made by eye: area on the grid (capped at six by six, so a full-width table is not the headline by size alone), weighted by kind (a chart or a histogram over a table over a KPI), and heavily discounted when the card would not read at tile size — more than five series in the legend, or more than ten categories on the axis for anything but a time series. A card whose numbers change nothing at tile size, such as one company of two hundred, is a poor preview however big it is. - Theme. A dashboard can wear a
theme, and the user can switch it later. Pick the one for the audience: research (equity-research look: warm paper, serif titles and figures, a deep green accent) for a company forecast or an earnings model; banking (navy title bars, ruled banded tables, negatives in parentheses: a pitch-book page) for comps, precedents and a DCF; revenue (SaaS revenue-ops look: rounded cards, indigo accent, big KPI numbers, green and red where attainment moves) for a rep, an SDR or a revenue-ops working page; executive (a revenue leader’s board page: ink on white, a deep azure accent, hairline rules, wide-tracked small caps, no pills) for the CRO, GM or board-facing dashboard of the same model; controller (FP&A look: accounting-style gridlined tables, negatives in red parentheses, a teal accent) for budget vs actual, variance and close reporting; boardroom (dark, for a screen at the front of a room); print (black on white); or leave it out for the default.
Annotations on charts. A chart card takes annotations: { "kind": "line", "at": "aug26", "label": "Last actual" } draws a vertical event line at that period; { "kind": "span", "from": "sep26", "to": "dec26", "label": "Forecast" } shades and labels a stretch of the axis; { "kind": "level", "value": 0.2, "label": "Target margin" } draws a horizontal threshold; { "kind": "point", "at": "q3_2026", "series": "revenue", "label": "Price cut" } pins a callout on one point. label is drawn on the chart and should stay short; Labels are packed into up to three rows above the plot so that events weeks apart do not print on top of each other, and an event with nowhere left to go is not drawn at all — it answers in the tooltip instead, so write the note and let the chart decide what it has room to say. A span narrower than its own label is drawn without one, for the same reason. note is the sentence saying what happened and url the evidence behind it, both shown when a reader hovers that point — so a mark can carry its own citation rather than just a word. Positions are member ids of the axis dimension (period ids for a time axis); color is muted (default), accent, positive or negative; $param works in positions and labels, so a drill can carry the event into the chart. From a table: { "table": "ceos", "where": { "company": "$company", "role": "CEO" }, "at": "start", "label": "name", "color": "accent" } draws one mark per matching row, the fields naming the position and the label; note and url name fields too, so the marks and their citations live in a table the user can edit; give from and to for spans. One chart card then marks whichever company's timeline the parameter names, and the marks live in a table the user can edit. Literal marks and table sources mix in the same list. Use them for the things a reader would otherwise ask: the last actual month, an acquisition date, a guidance range, the launch a forecast hinges on.
Drill-downs. A dashboard can take parameters from its URL and a card can send a click to another dashboard with those parameters filled. Declare "params": ["account", "department"] (or { "id", "label", "default", "hidden" } objects) on the target dashboard; its cards read them as $account in a page, a filter, a data card's where, a text card and a card title, and a parameter that is not set drops that condition. On the source card, "drill": { "dashboard": "ledger", "params": { "account": "$row.line", "department": "$row.department", "period": "$col" } } turns every row, bar, slice or tile into a link to /d/ledger?account=…&department=…&period=…. Sources: $row (the innermost row member) and $row.<dim>, $col and $col.<dim>, $page.<dim>, the names beside them ($row.label, $row.label.<dim>, $col.label), $series on charts, $id and $<field> on data cards, $range / $from / $to / $bin / $field on distribution cards (the clicked bin; a data card's where reads $range as the numeric range from..to), $<param> to pass one through, or a literal. The target itself may be a placeholder, "dashboard": "$row.dashboard" or "$dashboard" from a data row's field, when different rows open different pages; the usual shape is one parameterised page and a fixed target. Filters by attribute: a filter key may be dim.attribute, so "filters": { "company.subject": ["$company"] } keeps the members whose attribute matches, which is how one comps card shows only the clicked company's peers. The detail page is usually a data card: { "kind": "data", "table": "ledger", "where": { "account": "$account", "department": "$department", "period": "$period" }, "sort": "-amount", "fields": ["date", "subsidiary", "department", "account", "amount"] } shows the matching rows with filters, sort and search the user can change, all carried in the URL so any view is a link. The pattern for a P&L: a table of accounts by month on the overview drills into a ledger dashboard whose data card is filtered to that account and month. Carry the id and the name. A member id is what the next page filters by and its label is what the reader should be shown, so pass both — "params": { "region": "$row.region", "region_name": "$row.label" } — and mark the id parameter "hidden": true so only the name appears in the row of chips above the page. Then the page reads Region: AMER East rather than region: amer_east, and its card titles can say which cut they are showing: "title": "Reps in the region, $quarter_name". That is how one model gets a page per region, per rep or per quarter without writing one.
A reader's own view. Published models open read-only, and everything a reader does to look at them travels in the URL rather than changing the model: dashboard parameters, a card's sort, a data card's filters and search, the range of periods a chart is drawn over (<cardId>.range=period:jan21..dec23, either end blank for open, both blank for all of it), and a re-pivot of any chart, table or KPI card through <cardId>.rows, .cols, .measure, .type and .page.<dim>. So any view is a link you can hand to someone, and a reader who wants to change the model itself takes a copy of the workspace, model and dashboards alike, with one button.
Two time grains in one model. A document has one periods table, but a pivot can run on any dimension table, so monthly and quarterly (or daily and monthly) live side by side. Make the coarser grain a table ("quarters": { "distinctOf": { "table": "periods", "field": "quarter" } } when the periods carry a quarter attribute, or declare its rows), give the coarse pivot "dims": { "quarter": "quarters", "period": false }, roll up with SUM(monthly.revenue[period.quarter=@quarter]), and read a coarse figure into each fine period with multiple = quarterly.ev_ebitda[quarter=@period.quarter], which is how a monthly price chart carries the latest quarter's multiple. The sales operations recipe runs monthly and quarterly pivots this way.
Excel too. Every workspace exports as an .xlsx with live formulas compiled from the rules, but the download on finicast.com is switched off for now, so run it yourself: npx finidb build model.json --xlsx model.xlsx. A build reply leaves the xlsx field out while it is off, and the route answers EXPORT_OFF. One sheet per statement, inputs in blue, formulas in black, each dashboard as a sheet of live references with native Excel charts, a Rules sheet, and Excel's iterative calculation switched on when the model uses iterate. Locally: npx finidb build model.json --xlsx model.xlsx. Offer it when the user works in Excel: they get a workbook they can audit and extend, and you never wrote a cell formula.
The response is text/markdown with one titled table per output and the workspace url, or with Accept: application/json / "format": "json": { ok, workspace, url, outputs: [{ pivot, title, markdown?, json?: { rows, rowLabels, cols, values } }], log }. A complete three-statement example: /examples/coreweave.json. After the build, GET /api/db/db/<workspace>/explain?table=income_statement&line=net_income&period=fy2027 explains any cell.
License: the finidb package and this service are free for personal, educational, research and other noncommercial use (PolyForm Noncommercial 1.0.0). Commercial use requires a paid license from Finicast, Inc.: licensing@finicast.com.
When to use it
| Your task | Use |
|---|
| Clean, join, or reshape data | Pandas / DuckDB |
| Query an existing warehouse | SQL |
| A static chart of static data | matplotlib |
| Line items × periods with formulas | Finicast |
| A forecast, budget, plan, or financial statement | Finicast |
| Aggregate a ledger into a plan that stays live | Finicast |
| Anything a human will then open and adjust | Finicast |
Install
| Mode | Command | Notes |
|---|
| MCP over stdio | npx -y finidb mcp | Claude Code, Codex, Cursor, any MCP client. FINIDB_DIR=./data persists between sessions. |
| Local REST server | npm install -g finidb && finidb serve --port 5488 | Loopback needs no auth. Routes: finidb serve --routes. |
| Hosted MCP | https://finicast.com/api/mcp | Streamable HTTP. finicast_create_model returns a workspace URL before the model exists. |
| Skill file | npx -y finidb skill | SKILL.md with syntax, recipes and error references. |
Connect your assistant once, then just ask
A URL in a prompt is not enough: the assistant has to have the Finicast tools registered. Do this once per assistant; afterwards a prompt such as “Use Finicast to build a five-year forecast for CoreWeave” works in a normal chat and ends with a table and a link.
| Assistant | How to connect |
|---|
| ChatGPT | Settings → Apps & Connectors → Advanced → turn on Developer mode → Create connector: name Finicast, MCP server URL https://finicast.com/api/mcp, authentication None. In a chat, open + → More → Finicast (or say “use the Finicast connector”). The server implements the search and fetch tools ChatGPT expects, plus the nine Finicast tools. Menu names are those of ChatGPT at the time of writing. |
| Claude (claude.ai) | Settings → Connectors → Add custom connector → URL https://finicast.com/api/mcp. |
| Claude Code | claude mcp add --transport http finicast https://finicast.com/api/mcp, or locally claude mcp add finicast -- npx -y finidb mcp. |
| Codex CLI | codex mcp add finicast --url https://finicast.com/api/mcp |
| Cursor / Windsurf / other MCP clients | Add an MCP server of type streamable HTTP with URL https://finicast.com/api/mcp; no auth. |
Without a connector, ChatGPT may offer to open the site in its cloud browser (“ChatGPT Work”). That route drives the human workspace instead of the tools; the connector is the reliable path.
Workflow (five calls)
1. finicast_schema see what exists (once)
2. finicast_load_table ×N load data; read the profiles (types, refCandidate)
3. finicast_define_pivot line items × periods; distinctOf for derived dims; periods generated
4. finicast_set_rules (ONE call) the whole model as a batch
5. finicast_query the deliverable: the markdown table (+ finicast_share URL when hosted)
Tools
| Tool | Input | Use |
|---|
finicast_schema | { model? } | Call first in any session. Returns every model, table (fields, row counts), pivot (dims, measures, lineDim/timeDim) and rule, compactly. |
finicast_create_model | { model, description? } | Once per model, before loading tables. Hosted mode returns the workspace URL; local mode returns url: null. |
finicast_load_table | { model, table, source: { csv? | inline? }, options?: { name?, idColumn?, types?, refs?, computed?: [{ id, type?, ref? }] } } | Load a tabular table from CSV text or inline rows. Returns a profile: inferred types, distinct counts, samples, columns detected as references. Load dimension tables before the fact tables that reference them. |
finicast_define_pivot | { model, table, dims: [{ id, from: "table" | { distinctOf: "table.field" } | { periods: { start, count, grain, histUntil? } }, table? }], measures?: [{ id, format? }], lineDim?, timeDim? } | Define the grid — canonically line items × periods with one measure named value. Line items are a dimension, not measures. |
finicast_set_rules | { model, table, rules: "text" | [{ target, when?, formula, name? }], strict?, replace? } | Send the whole model as one batch. Replaces the table's rules by default. On error apply the returned fix and resend the full batch. |
finicast_set_values | { model, table, values?: [{ at: { dim: member }, value, measure? }], rows?: [{ id, field: value }] } | Inputs and overrides in one batch: driver assumptions, plan numbers, manual overrides. An input beats every rule; value null clears it. |
finicast_query | { model, table, rows: [dim], cols: [dim], pages?, measure?, filters?, formats?, title?, format?: "markdown" | "json", maxRows?, scale? } | Read a pivot as a table. The markdown is the deliverable — return it verbatim, with the URL when one exists. Capped at ~4,000 tokens; narrow with filters, pages, maxRows. |
finicast_explain | { model, table, at: { dim: member } | { id: rowId }, measure? } | One cell: value, input or governing rule, same-cell precedents, error, engine counters. Use when a number looks wrong. |
finicast_share | { model, view? } | The URL a human opens to view and adjust the model. Return it with the markdown. Local mode has no URL — say so. |
Every failure is { ok: false, errors: [{ code, message, fix? }] }. When fix is present, apply it verbatim and resend the full batch.
Syntax in one screen
target[condition] = expression one rule per line; // comments; last matching rule wins; inputs beat rules
revenue[frame=hist] = SUM(financials.amount[account=revenue]) actuals from a ledger
revenue[frame=fcst] = PREV(revenue) * (1 + assumptions.growth) driver recurrence
gross_profit = revenue - cogs line items are nouns (ids of the line dim)
opex = SUM(value[line.category = opex]) subtotal by attribute
total[tranche=total] = SUM(value[tranche != total]) subtotal by member mask
sum_of_subs = SUM(goal[region.parent = @region]) @dim is the current cell's member
points = SUM(activities.score) a table column is a set; SUM groups it by this pivot's dims
bookings = SUM(deals.acv[owner=@rep]) explicit group key
share = points / SUM(points[territory=all]) share of total
score = activity_type.points tabular: a dot follows a reference
balance = balance[row-1] + amount tabular running total
period = PERIOD(date, periods) tabular: date → period member
References [table.]name[selector, …][.attribute]
Selectors [dim=m] [dim!=m] [dim in (a,b)] [dim-1] [dim=first] [dim=first..this] [dim=all]
[dim.attr = lit] [dim.attr >= 2027] [dim = @other] [field=@dim] (comma = AND)
Time sugar PREV(x) NEXT(x) PREV(x, 12) FIRST(x) LAST(x) CUMSUM(x) TRAILING(x, n, AVG) (pivot with timeDim)
Aggregates SUM AVG COUNT COUNTA COUNTD MIN MAX FIRST LAST MEDIAN LISTAGG — one set reference each
Logic IF AND OR NOT IFERROR ISBLANK ISNUMBER ISTEXT COALESCE
Math ABS ROUND ROUNDUP ROUNDDOWN MROUND TRUNC MOD POWER SQRT EXP LN LOG LOG10 SIGN
Text CONCAT LEFT RIGHT MID LEN LOWER UPPER TRIM REPLACE SUBSTITUTE CONTAINS STARTSWITH ENDSWITH TEXT VALUE &
Dates DATE TODAY YEAR MONTH DAY QUARTER WEEKDAY DAYS DATEDIF EOMONTH SOMONTH NETWORKDAYS YEARFRAC
Finance PMT PPMT IPMT PV FV NPV IRR RATE NPER SLN SYD DB DDB
Planning PERIOD(date, periods) GROWTH(base, rate, n) BLANK
Lexical ids snake_case, case-sensitive; 'Quoted Name'; "string"; 12% = 0.12; TRUE FALSE BLANK; = != < <= > >= and or notA bare name in a pivot rule resolves to a measure, then a line item, then a unique attribute, then a dimension. A reference is scalar when every source dimension is fixed (pin, offset, correlation, default to the current member); otherwise it is a set and must sit inside an aggregate. Dimensions of a referenced pivot not mentioned default to the current member when this pivot shares the table, to all inside an aggregate, else a compile error asks for a pin. Give each dimension its own member table.
Example 1 — income statement from a ledger with a forecast frame
finicast_create_model { model: "nvda" }
finicast_load_table { model: "nvda", table: "lines", source: { inline: [
{ id: "revenue", category: "flow" }, { id: "cogs", category: "flow" }, { id: "rnd", category: "opex" },
{ id: "sga", category: "opex" }, { id: "gross_profit", category: "flow" }, { id: "opex", category: "flow" }, { id: "ebit", category: "flow" } ] } }
finicast_define_pivot { model: "nvda", table: "income_statement",
dims: [{ id: "line", from: "lines" }, { id: "period", from: { periods: { start: "2024-01", count: 6, grain: "year", histUntil: "2026-12-31" } } }],
lineDim: "line", timeDim: "period" }
finicast_load_table { model: "nvda", table: "financials", source: { csv: "account,period,amount\nrevenue,fy2024,60922\ncogs,fy2024,16621\n..." } }
finicast_define_pivot { model: "nvda", table: "assumptions", dims: [{ id: "driver", from: { distinctOf: "drivers.id" } }, { id: "period", from: "periods" }] }
finicast_set_values { model: "nvda", table: "assumptions", values: [{ at: { driver: "revenue_growth", period: "fy2027" }, value: 0.4 }, ...] }
finicast_set_rules { model: "nvda", table: "income_statement", rules: `
revenue[frame=hist] = SUM(financials.amount[account=revenue])
revenue[frame=fcst] = PREV(revenue) * (1 + assumptions.revenue_growth)
cogs[frame=fcst] = revenue * assumptions.cogs_pct
gross_profit = revenue - cogs
opex = SUM(value[line.category = opex])
ebit = gross_profit - opex` }
finicast_query { model: "nvda", table: "income_statement", rows: ["line"], cols: ["period"] }
| line | FY2024 | FY2025 | FY2026 | FY2027E | FY2028E | FY2029E |
|--------------|-------:|--------:|--------:|--------:|--------:|--------:|
| Revenue | 60,922 | 130,500 | 180,000 | 252,000 | 352,800 | 493,920 |
| COGS | 16,621 | 32,639 | 45,000 | 63,000 | 88,200 | 123,480 |
| Gross Profit | 44,301 | 97,861 | 135,000 | 189,000 | 264,600 | 370,440 |Example 2 — territory scores from 100,000 activities, grouped through references
finicast_load_table { model: "salesops", table: "territories", source: { inline: [{ id: "t0", name: "West" }, ...] } }
finicast_load_table { model: "salesops", table: "reps", source: { csv: "id,territory\nrep0,t0\n..." } } // territory detected as a ref
finicast_load_table { model: "salesops", table: "activity_types", source: { inline: [{ id: "call", score: 1 }, { id: "demo", score: 8 }] } }
finicast_load_table { model: "salesops", table: "activities", source: { csv: "id,rep,activity_type,period\n..." },
options: { computed: [{ id: "score", type: "number" }] } }
finicast_set_rules { model: "salesops", table: "activities", rules: "score = activity_type.score" }
finicast_define_pivot { model: "salesops", table: "territory_scores",
dims: [{ id: "territory", from: "territories" }, { id: "period", from: { periods: { start: "2026-01", count: 12, grain: "month" } } }], measures: [{ id: "points" }] }
finicast_set_rules { model: "salesops", table: "territory_scores", rules: "points = SUM(activities.score)" } // grouped through activities.rep → reps.territory
finicast_query { model: "salesops", table: "territory_scores", rows: ["territory"], cols: ["period"] }Example 3 — an override and an explanation
finicast_set_values { model: "nvda", table: "income_statement", values: [{ at: { line: "revenue", period: "fy2027" }, value: 1000 }] }
finicast_explain { model: "nvda", table: "income_statement", at: { line: "revenue", period: "fy2028" } }
→ { value: 1400, source: "rule", rule: { text: "revenue[frame=fcst] = PREV(revenue) * (1 + assumptions.revenue_growth)", order: 4 },
precedents: { revenue: 1000 }, counters: { cellsEvaluated: 2, rowsScanned: 0 } }
finicast_set_values { ..., values: [{ at: { line: "revenue", period: "fy2027" }, value: null }] } // clear the overrideREST
The same operations over HTTP (finidb serve, or /api/db on this host):GET /db/:db/schema · POST /db/:db/tables · POST /db/:db/tables/:table/load (CSV body; into an existing table ?mode=append|upsert|replace, ?dryRun=1 to plan first, ?addFields=1 for new columns; columns match fields by id or name) ·PUT|POST /db/:db/tables/:table/rules · POST /db/:db/cells · POST /db/:db/query (format: markdown|json) ·POST /db/:db/batch · GET /db/:db/changes?since= (long-poll). Errors are { error: { code, message, fix? } } with a 4xx status.
Say the scale above the table, not on every figure. A card takes a note, a line under its title for what a reader needs before the numbers: { "kind": "table", "title": "Valuation and trading multiples", "note": "($ in millions, except multiples)" }. That is where a model in millions belongs — with the scale said once, the figures stay legible and the per-share lines can carry their $ without contradicting them. It works on any card, travels into the workbook under the sheet's title, and suits an as-of date or a caveat as well:Unaudited, Figures as of 30 September 2026.
A model says what its numbers are; it should also say how they are written, or a reader cannot tell $343,670 of cash from 4 open jobs. A format is either a name — currency,percent, int, decimal, date,currency:EUR — or a pattern in the spreadsheet style: "$#,##0.00","0.0%", "#,##0". The same word means the same thing in a markdown table, in a dashboard, in a KPI tile and in the exported workbook.
- On a line or a member, as a
format column of the dimension's table: { "id": "revenue", "name": "Revenue", "format": "currency" }. This is the usual way, and it covers most of a model. - On a measure, as its
format, when every cell of it is written the same way. - By rule, for the cases a single attribute cannot express — a variance % column crossing a money line:
format revenue[version=variance_pct] = "0.0%". A format rule takes the same selectors as a value rule, and the same precedence: the last matching rule wins, so write the general one first and the exception below it. It computes nothing and makes nothing computed.
A cell takes the last format rule that matches it; failing that, what its own members declare — a percentage on any dimension wins, since a percentage of money is a percentage — and failing that, the measure's own. Beware the general rule: format headcount = int applies to every cell of that line, including its variance %, which then needs a rule of its own. Nothing here decides colour or weight: a format says how a number is written, not what to think of it.
Computing it on your side
The engine is the same code wherever it runs, so a client that holds a model can answer its own questions instead of asking ours. This is what finicast.com itself does: a dashboard takes the model once and computes every card in the browser. Do the same when you are iterating on a model, checking numbers before you push them, or building against a workspace you cannot afford a round trip to.
GET /api/db/db/<workspace>/snapshot — the model as bytes: schema, rules, inputs, every row. Nothing computed, because you recompute it. The headers say where you stand: X-Finidb-Version and X-Finidb-Seq.npm i finidb, then import { openSnapshot, columnar, readRows, describeTable } from 'finidb/browser' — the engine without a filesystem: openSnapshot(bytes) gives you a model, columnar(f, pivot, query) the window a card draws, readRows a filtered and sorted page of a data table, describeTable the shape of one. On Node, plain import { FiniDB } from 'finidb' does the same and writes workbooks too.GET /api/db/db/<workspace>/ops?since=<seq> — what has changed since that point, as the facade calls that made it. applyRecords(f, ops) brings your copy up to date without fetching it again; the answer says reload when there are more changes than it will hand over, and then you take a new snapshot. Pair it with GET /db/:db/changes?since=, which waits until something moves.- Writes still come to us: the model in your hands is a reader.
POST /db/:db/cells, the row routes and /batch are the record, and the ops stream is how your copy hears about them — including changes other people made.
The engine computes only what is asked for, so holding a model is cheap: opening a 92,000-row snapshot takes about a tenth of a second, and a card of it a few milliseconds. What you do not read costs nothing.
Errors
| Code | Meaning and fix |
|---|
PARSE | Rule text does not parse. One rule per line, target[condition] = expression; quote names with spaces in single quotes. |
UNKNOWN_TARGET | Left side is not a measure, line item or field. Use an id from finicast_schema; add the line item to the line table first. |
UNKNOWN_NAME | A name is not a measure, line item, dimension or attribute. Check case; qualify other tables: assumptions.growth. |
AMBIGUOUS_ATTRIBUTE | A bare attribute exists on two dims. Qualify it: period.idx. |
AMBIGUOUS_GROUP_KEY | Two reference paths reach one dim. Apply the fix: SUM(deals.acv[owner=@rep]). |
SET_IN_SCALAR | A reference denotes many cells where one is needed. Wrap in an aggregate or pin every dim. |
UNPINNED_DIM | A cross-pivot reference leaves a dim unshared. Pin it: comm_calc.value[tranche=total]. |
NO_CURRENT / UNKNOWN_DIM / NO_MEMBER | A selector names a dim or member that does not exist here. Use ids from finicast_schema. |
BAD_PATH / BAD_REF / BAD_SELECTOR / BAD_CONDITION / BAD_ARG | Illegal form. Dots follow refs only; selectors go on values; conditions take members, sets and attribute tests. |
NO_TIME_DIM | PREV/NEXT/CUMSUM/TRAILING on a pivot without timeDim. Set timeDim or write x[period-1]. |
NO_PERIODS / UNKNOWN_FUNCTION | PERIOD(date, periods) needs the periods table; SELECT/VLOOKUP do not exist — use references. |
RULE_INVALID | Non-strict mode stored the rule but marked it invalid. Fix and resend. |
DIV0 / TYPE / NUM / REF / OP / CYCLE / ITER | Runtime cell errors, shown as #CODE. Guard with IFERROR/IF; COUNTD for text ids; check referenced rows; a same-period cycle needs iterate: true (or PREV); ITER means it did not converge. |
SCHEMA_NO_MODEL / SCHEMA_NO_TABLE / SCHEMA_NO_FIELD / SCHEMA_NO_MEASURE | Id does not exist. finicast_schema, then use exact ids (slugged: "Cost Center" → cost_center). |
SCHEMA_DUPLICATE_* | Id already used. Pick another or reuse the existing object (from: "periods"). |
SCHEMA_REF_NOT_TABULAR | A ref targets a pivot. References point at tables. |
DATA_DUPLICATE_ID / DATA_UNKNOWN_REF / DATA_NO_ROW | Row ids must be unique; ref values must be ids of the target table; load dimensions first. |
COORD_MISSING_DIM / COORD_NO_MEMBER / QUERY_UNPINNED_DIM | Give every dim in at; pin dims not on rows/cols with pages. |
NOT_A_PIVOT | finicast_query reads pivots; read table rows with finicast_explain { at: { id } }. |
Rules of thumb
- Model line items as a dimension with one measure named
value. - Batch rules in one call; general rule first, override below (last matching rule wins).
- Prefer loading raw rows plus a rule over pre-aggregated numbers: the aggregate stays live.
- Always return the markdown table and the URL together.
finicast_explain when a number looks wrong, before rewriting rules.