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.

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 (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:

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 taskUse
Clean, join, or reshape dataPandas / DuckDB
Query an existing warehouseSQL
A static chart of static datamatplotlib
Line items × periods with formulasFinicast
A forecast, budget, plan, or financial statementFinicast
Aggregate a ledger into a plan that stays liveFinicast
Anything a human will then open and adjustFinicast

Install

ModeCommandNotes
MCP over stdionpx -y finidb mcpClaude Code, Codex, Cursor, any MCP client. FINIDB_DIR=./data persists between sessions.
Local REST servernpm install -g finidb && finidb serve --port 5488Loopback needs no auth. Routes: finidb serve --routes.
Hosted MCPhttps://finicast.com/api/mcpStreamable HTTP. finicast_create_model returns a workspace URL before the model exists.
Skill filenpx -y finidb skillSKILL.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.

AssistantHow to connect
ChatGPTSettings → 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 Codeclaude mcp add --transport http finicast https://finicast.com/api/mcp, or locally claude mcp add finicast -- npx -y finidb mcp.
Codex CLIcodex mcp add finicast --url https://finicast.com/api/mcp
Cursor / Windsurf / other MCP clientsAdd 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

ToolInputUse
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 not

A 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 override

REST

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) ·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.

Errors

CodeMeaning and fix
PARSERule text does not parse. One rule per line, target[condition] = expression; quote names with spaces in single quotes.
UNKNOWN_TARGETLeft 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_NAMEA name is not a measure, line item, dimension or attribute. Check case; qualify other tables: assumptions.growth.
AMBIGUOUS_ATTRIBUTEA bare attribute exists on two dims. Qualify it: period.idx.
AMBIGUOUS_GROUP_KEYTwo reference paths reach one dim. Apply the fix: SUM(deals.acv[owner=@rep]).
SET_IN_SCALARA reference denotes many cells where one is needed. Wrap in an aggregate or pin every dim.
UNPINNED_DIMA cross-pivot reference leaves a dim unshared. Pin it: comm_calc.value[tranche=total].
NO_CURRENT / UNKNOWN_DIM / NO_MEMBERA 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_ARGIllegal form. Dots follow refs only; selectors go on values; conditions take members, sets and attribute tests.
NO_TIME_DIMPREV/NEXT/CUMSUM/TRAILING on a pivot without timeDim. Set timeDim or write x[period-1].
NO_PERIODS / UNKNOWN_FUNCTIONPERIOD(date, periods) needs the periods table; SELECT/VLOOKUP do not exist — use references.
RULE_INVALIDNon-strict mode stored the rule but marked it invalid. Fix and resend.
DIV0 / TYPE / NUM / REF / OP / CYCLERuntime 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_MEASUREId 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_TABULARA ref targets a pivot. References point at tables.
DATA_DUPLICATE_ID / DATA_UNKNOWN_REF / DATA_NO_ROWRow ids must be unique; ref values must be ids of the target table; load dimensions first.
COORD_MISSING_DIM / COORD_NO_MEMBER / QUERY_UNPINNED_DIMGive every dim in at; pin dims not on rows/cols with pages.
NOT_A_PIVOTfinicast_query reads pivots; read table rows with finicast_explain { at: { id } }.

Rules of thumb