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.
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 (Claude.ai and ChatGPT code execution allow only package registries by default): install the engine and compute locally. npm install finidb then npx finidb build model.json prints the same statements from the same document and a link of the form https://finicast.com/import#m=… that carries the whole model in its fragment. Put that link in your reply exactly as printed (as a markdown link is fine): when the user clicks it, their browser builds the workspace from it; nothing needs to reach finicast.com from your side. Without the link, the user can paste model.json at finicast.com/import. The user can also allow finicast.com in their assistant's network settings, or add the connector (below), after which POST /api/build works directly.
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, the natural place for reported history. 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. What it does not do is iterate a same-period circularity (interest on average debt when debt depends on interest): that cell returns #CYCLE, so drive such lines off the prior-period balance. 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]).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.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": "…", "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" }] }]. Card kinds: table (optional lines, editable), chart (lines, optional type line | bar | stackedBar | area | waterfall), kpi (line; shows the last period and the change). Chart convention: 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. Layout is automatic; w/h in a 12-column grid override it. "dashboards": "auto" derives one from the input-only pivots and the outputs. With a dashboard, the returned url opens it.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.
| 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 |
| 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. |
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.
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)
| 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.
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.
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 |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"] }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 overrideThe 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) ·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.
| 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 | Runtime cell errors, shown as #CODE. Guard with IFERROR/IF; COUNTD for text ids; check referenced rows; break the cycle. |
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 } }. |
value.finicast_explain when a number looks wrong, before rewriting rules.