Agents: fetch this page with Accept: text/markdown or /guide?format=md for the raw Markdown.

FiniDB agent guide

This text is also the MCP resource finidb://guide/full, finidb guide --full and GUIDE.md in the npm package; the short version is the package README (finidb guide, /agent).

1. What FiniDB is

FiniDB is a modeling database for agents: named tables, pivot tables (a grid of line items × periods with formulas placed by condition), and methods, formulas attached to a condition instead of to a cell. A changed input recomputes only its dependents, in milliseconds, even with 100,000-row fact tables; every cell can explain which method and inputs produced it. It runs embedded (npx finidb … against a directory), as a daemon (finidb serve), or hosted at finicast.com, with the same commands. The model is one reviewable file: finidb apply model.json builds it, export script writes it back.

Why not a spreadsheet: =SUMIFS($C:$C,$A:$A,$A2,$B:$B,B$1) copied over 720 cells is one method here, frame = hist: SUM(SELECT("amount","ledger","account","=",THIS("account"),"period","=",THIS("period"))); no coordinates, no copy-down, no ranges that break when a row is added.

2. Workflow and concepts

Write the whole model as one script, apply it, look, verify, deliver. apply is idempotent: edit the file and apply again after every change; it updates what differs (structure, methods, rows) and reports the diff.

npx finidb apply model.json --data ./model      # build or update the model from one file (--dry-run previews)
npx finidb query ./model pl                      # render a pivot as markdown (--cdim <id> for another value dim)
npx finidb errors ./model                        # must print "no errors" before you report done
npx finidb formula explain ./model pl value 8    # which method and inputs made cell 8 (row * columns + column)
npx finidb export ./model xlsx --out model.xlsx  # the deliverable; export ./model script --model-script writes the file back

MCP: finidb_apply {script} or {path}, finidb_render, finidb_list_errors, finidb_explain, finidb_query (ad-hoc formulas), finidb_export. finidb_commands (a POST /commands batch, §3) is the incremental path for one-off edits and for adding rows to a live database; finidb_describe first on a database you did not build.

Database › Model m1
  ledger   (tabular) id, account→accounts, period→periods, amount   fact rows
  accounts (tabular) id, Name          periods (tabular) id, Name, Frame (hist|fcst)
  pl       (pivot)   vdimIds [account]  hdimIds [period]  cdimIds [value]
                     linked pdim frame ← periods.Frame   periodsDimId period
                     methods, last match wins: frame = hist → SUM(SELECT(…)); account = gp → 'rev' - 'cogs'

Cell precedence: per-cell formula (=…) > entered value > last matching method > blank.

2.1 The model script

One JSON (or YAML) file {models, tables, methods, data} (§5.1 is a complete one). apply diffs it against the database: create-or-update, --dry-run validates and plans exactly like the real run, --prune also removes what the file omits. Unknown keys are rejected with their path (tables[0].periodsDimId (did you mean pivot.periodsDimId?)).

3. The five commands you need

Send commands as a batch to POST /commands (MCP finidb_commands; atomic: a bad step rolls back the batch and names the step). Paths are relative to the database base URL (https://finicast.com/db/<project> hosted, http://localhost:7407/v1/db/<db> local daemon).

[
{"type":"CREATE_TABLE","data":{"modelId":"m1","id":"periods","name":"Periods"}},
{"type":"CREATE_DIM","data":{"modelId":"m1","tableId":"periods","id":"Frame","type":"string"}},
{"type":"CREATE_MANY","data":{"modelId":"m1","tableId":"periods","records":[["2026-01","hist"],["2026-02","fcst"]]}},
{"type":"SET_PIVOT","data":{"modelId":"m1","tableId":"pl","vdimIds":["account"],"hdimIds":["period"],"cdimIds":["value"]}},
{"type":"CREATE_METHOD","data":{"modelId":"m1","tableId":"pl","name":"fcst","dimId":"value","condition":[{"left":"frame","comparison":"=","right":"fcst"}],"formula":"PREV(\"value\") * 1.05"}}
]

4. Formulas

Restrictions: R1 table/model/dim names in SELECT, THIS, PTHIS, PPRIOR, PREF, ALL, CUMULATIVE, LOOKUP, PERIOD are string literals. R2 a nested SELECT in a condition value is allowed; scalar equality forms stay incremental, other forms make the rule coarse. R3 no EVAL. R4 a cycle is an error, except time recursion: a dim reading itself through PREV/NEXT/PPRIOR/CUMULATIVE, and cdims of one pivot whose only back-edges are such shifts of each other (a period group, settled period by period, §5.3). Same-period cycles and shifts pointing both ways (a self PREV fed by a sibling's NEXT) are refused. R5 RAND, RANDBETWEEN, TODAY recompute only on explicit recompute.

5. Recipes

Each recipe is one model script for an empty directory: save it as model.json, npx finidb apply model.json --data ./model, then npx finidb query ./model <pivot>. 5.2–5.4 omit "models":[{"id":"m1"}] for brevity; add it.

5.1 Income statement with hist/fcst frames and growth assumptions

{"models":[{"id":"m1"}],
 "tables":[
  {"modelId":"m1","id":"periods","dims":[{"id":"Frame","type":"string"}]},
  {"modelId":"m1","id":"accounts","dims":[{"id":"Name","type":"string"}]},
  {"modelId":"m1","id":"assumptions","dims":[{"id":"value","type":"number"}]},
  {"modelId":"m1","id":"ledger","dims":[
    {"id":"account","refModelId":"m1","refTableId":"accounts","refDimId":"id"},
    {"id":"period","refModelId":"m1","refTableId":"periods","refDimId":"id"},
    {"id":"amount","type":"number"}]},
  {"modelId":"m1","id":"pl","isPivot":true,"dims":[
    {"id":"account","refModelId":"m1","refTableId":"accounts","refDimId":"id"},
    {"id":"period","refModelId":"m1","refTableId":"periods","refDimId":"id"},
    {"id":"value","type":"number"},
    {"id":"frame","linkedToPdimId":"period","fetchDimId":"Frame"}],
   "pivot":{"vdimIds":["account"],"hdimIds":["period"],"cdimIds":["value"],"periodsDimId":"period"}}],
 "methods":[
  {"modelId":"m1","tableId":"pl","name":"hist","dimId":"value","condition":[{"left":"frame","comparison":"=","right":"hist"}],
   "formula":"SUM(SELECT(\"amount\",\"ledger\",\"account\",\"=\",THIS(\"account\"),\"period\",\"=\",THIS(\"period\")))"},
  {"modelId":"m1","tableId":"pl","name":"fcst rev","dimId":"value","condition":[{"left":"frame","comparison":"=","right":"fcst"},{"left":"account","comparison":"=","right":"rev"}],
   "formula":"PREV(\"value\") * (1 + LOOKUP(\"value\",\"assumptions\",\"id\",\"growth\"))"},
  {"modelId":"m1","tableId":"pl","name":"fcst cogs","dimId":"value","condition":[{"left":"frame","comparison":"=","right":"fcst"},{"left":"account","comparison":"=","right":"cogs"}],
   "formula":"PTHIS(\"account\",\"rev\") * LOOKUP(\"value\",\"assumptions\",\"id\",\"cogs_pct\")"},
  {"modelId":"m1","tableId":"pl","name":"gp","dimId":"value","condition":[{"left":"account","comparison":"=","right":"gp"}],"formula":"'rev' - 'cogs'"}],
 "data":{
  "m1:periods":[["2026-01","hist"],["2026-02","hist"],["2026-03","fcst"],["2026-04","fcst"]],
  "m1:accounts":[["rev","Revenue"],["cogs","COGS"],["gp","Gross profit"]],
  "m1:assumptions":[["growth",0.05],["cogs_pct",0.4]],
  "m1:ledger":{"dimIds":["id","account","period","amount"],"records":[["l1","rev","2026-01",1000],["l2","cogs","2026-01",400],["l3","rev","2026-02",1100],["l4","cogs","2026-02",430]]}}}

Verify: query ./model plrev = 1000, 1100, 1155, 1212.75; gp / 2026-03 = 693. Reconcile rev / 2026-01 with finidb_query SUM(SELECT("amount","ledger","account","=","rev","period","=","2026-01")) = 1000. What-if: change growth to 0.1 in the file and re-apply (or SET_VALUES {"values":[{"dimId":"value","recordId":"growth","value":0.1}]}); only fcst cells change.

5.2 Sales-ops activity scoring (CSV → territory pivot)

Benchmark shape: 12 periods, 200 reps, 100,000 activities. Computed columns are methods on the fact table; the rows come from finidb import csv ./model activities.csv --table activities (id, rep_id, type, date; the id column must be unique) or, as here, from data.

{"tables":[
  {"modelId":"m1","id":"periods","dims":[{"id":"Frame","type":"string"}]},
  {"modelId":"m1","id":"reps","dims":[]},
  {"modelId":"m1","id":"territory_list","dims":[]},
  {"modelId":"m1","id":"territories","dims":[{"id":"rep_id","refModelId":"m1","refTableId":"reps","refDimId":"id"},{"id":"territory_id","type":"string"}]},
  {"modelId":"m1","id":"scoring","dims":[{"id":"type","type":"string"},{"id":"score","type":"number"}]},
  {"modelId":"m1","id":"activities","dims":[
    {"id":"rep_id","refModelId":"m1","refTableId":"reps","refDimId":"id"},{"id":"type","type":"string"},{"id":"date","type":"date"},
    {"id":"period","type":"string"},{"id":"score","type":"number"},{"id":"territory","type":"string"}],
   "methods":[
    {"name":"period","dimId":"period","condition":[],"formula":"PERIOD(THIS(\"date\"),\"periods\")"},
    {"name":"score","dimId":"score","condition":[],"formula":"SELECT(\"score\",\"scoring\",\"type\",\"=\",THIS(\"type\"))"},
    {"name":"territory","dimId":"territory","condition":[],"formula":"SELECT(\"territory_id\",\"territories\",\"rep_id\",\"=\",THIS(\"rep_id\"))"}]},
  {"modelId":"m1","id":"territory_scores","isPivot":true,"dims":[
    {"id":"territory_id","refModelId":"m1","refTableId":"territory_list","refDimId":"id"},
    {"id":"period","refModelId":"m1","refTableId":"periods","refDimId":"id"},
    {"id":"score","type":"number"},{"id":"score_fcst","type":"number"},
    {"id":"frame","linkedToPdimId":"period","fetchDimId":"Frame"}],
   "pivot":{"vdimIds":["territory_id"],"hdimIds":["period"],"cdimIds":["score","score_fcst"],"periodsDimId":"period"},
   "methods":[
    {"name":"score","dimId":"score","condition":[],"formula":"SUM(SELECT(\"score\",\"activities\",\"territory\",\"=\",THIS(\"territory_id\"),\"period\",\"=\",THIS(\"period\")))"},
    {"name":"hist","dimId":"score_fcst","condition":[{"left":"frame","comparison":"=","right":"hist"}],"formula":"THIS(\"score\")"},
    {"name":"fcst","dimId":"score_fcst","condition":[{"left":"frame","comparison":"=","right":"fcst"}],"formula":"PREV(\"score_fcst\") * 1.05"}]}],
 "data":{
  "m1:periods":[["2026-01","hist"],["2026-02","hist"],["2026-03","fcst"],["2026-04","fcst"]],
  "m1:reps":[["r1"],["r2"]],
  "m1:territory_list":[["west"],["east"]],
  "m1:territories":[["t1","r1","west"],["t2","r2","east"]],
  "m1:scoring":[["s1","call",1],["s2","email",0.5],["s3","demo",5]],
  "m1:activities":{"dimIds":["id","rep_id","type","date"],"records":[["a1","r1","call","2026-01-05"],["a2","r1","demo","2026-01-20"],["a3","r2","email","2026-02-02"]]}}}

The activity rows name their columns with dimIds because the table's last three dims are computed: a positional row ["a1","r1","call","2026-01-05",…] would be fine too, but one with a fifth value would override the period method for that row. Verify: west / 2026-01 = 6 (call 1 + demo 5); the pivot has two value dims, query --cdim score_fcst shows the other. Reconcile SUM(SELECT("score","activities","period","=","2026-01")) with the column total.

5.3 Headcount and payroll plan by department × month

{"tables":[
  {"modelId":"m1","id":"months","dims":[{"id":"Frame","type":"string"}]},
  {"modelId":"m1","id":"departments","dims":[{"id":"salary","type":"number"},{"id":"start","type":"number"},{"id":"rate","type":"number"}]},
  {"modelId":"m1","id":"hiring","dims":[{"id":"dept","refModelId":"m1","refTableId":"departments","refDimId":"id"},{"id":"month","refModelId":"m1","refTableId":"months","refDimId":"id"},{"id":"hires","type":"number"}]},
  {"modelId":"m1","id":"plan","isPivot":true,"dims":[
    {"id":"dept","refModelId":"m1","refTableId":"departments","refDimId":"id"},
    {"id":"month","refModelId":"m1","refTableId":"months","refDimId":"id"},
    {"id":"headcount","type":"number"},{"id":"hires","type":"number"},{"id":"attrition","type":"number"},{"id":"payroll","type":"number"},
    {"id":"frame","linkedToPdimId":"month","fetchDimId":"Frame"}],
   "pivot":{"vdimIds":["dept"],"hdimIds":["month"],"cdimIds":["headcount","hires","attrition","payroll"],"periodsDimId":"month"},
   "methods":[
    {"name":"hires","dimId":"hires","condition":[],"formula":"SUM(SELECT(\"hires\",\"hiring\",\"dept\",\"=\",THIS(\"dept\"),\"month\",\"=\",THIS(\"month\")))"},
    {"name":"opening","dimId":"headcount","condition":[{"left":"frame","comparison":"=","right":"hist"}],"formula":"LOOKUP(\"start\",\"departments\",\"id\",THIS(\"dept\"))"},
    {"name":"roll forward","dimId":"headcount","condition":[{"left":"frame","comparison":"=","right":"fcst"}],
     "formula":"PREV(\"headcount\") + THIS(\"hires\") - THIS(\"attrition\")"},
    {"name":"attrition","dimId":"attrition","condition":[{"left":"frame","comparison":"=","right":"fcst"}],"formula":"INT(PREV(\"headcount\") * LOOKUP(\"rate\",\"departments\",\"id\",THIS(\"dept\")))"},
    {"name":"payroll","dimId":"payroll","condition":[],"formula":"THIS(\"headcount\") * LOOKUP(\"salary\",\"departments\",\"id\",THIS(\"dept\"))"}]}],
 "data":{
  "m1:months":[["2026-01","hist"],["2026-02","fcst"],["2026-03","fcst"]],
  "m1:departments":[["eng",9000,10,0.1],["sales",6000,4,0.2]],
  "m1:hiring":[["h1","eng","2026-02",2]]}}

headcount and attrition read each other, but only backwards in time, so they form a period group settled month by month (R4); no need to fold attrition into the headcount rule. Verify: eng headcount is 10, 11, 10; attrition (--cdim attrition) 1 for eng in both fcst months; payroll eng / 2026-02 = 99000. Reconcile SUM(ALL("dept")) on payroll / 2026-01 = 114000. What-if without touching the file: SET_VALUES {"values":[{"dimId":"hires","coords":{"dept":"sales","month":"2026-02"},"value":1}]} enters one hire for sales / 2026-02; an entered value beats the hires method for that cell.

5.4 Cohort retention

{"tables":[
  {"modelId":"m1","id":"periods","dims":[]},
  {"modelId":"m1","id":"cohorts","dims":[{"id":"start","refModelId":"m1","refTableId":"periods","refDimId":"id"},{"id":"size","type":"number"}]},
  {"modelId":"m1","id":"rates","dims":[{"id":"name","type":"string"},{"id":"value","type":"number"}]},
  {"modelId":"m1","id":"retention","isPivot":true,"dims":[
    {"id":"cohort","refModelId":"m1","refTableId":"cohorts","refDimId":"id"},
    {"id":"period","refModelId":"m1","refTableId":"periods","refDimId":"id"},
    {"id":"users","type":"number"},
    {"id":"start","linkedToPdimId":"cohort","fetchDimId":"start"}],
   "pivot":{"vdimIds":["cohort"],"hdimIds":["period"],"cdimIds":["users"],"periodsDimId":"period"},
   "methods":[{"name":"decay","dimId":"users","condition":[],
    "formula":"IF(THIS(\"period\") < THIS(\"start\"), \"\", IF(THIS(\"period\") = THIS(\"start\"), LOOKUP(\"size\",\"cohorts\",\"id\",THIS(\"cohort\")), PPRIOR(\"period\", -1) * LOOKUP(\"value\",\"rates\",\"name\",\"retention\")))"}]}],
 "data":{
  "m1:periods":[["2026-01"],["2026-02"],["2026-03"],["2026-04"]],
  "m1:cohorts":[["c1","2026-01",1000],["c2","2026-02",800]],
  "m1:rates":[["r1","retention",0.8]]}}

PPRIOR("period", -1) reads this cdim one period back; before the first period it is blank, not an error. Verify: c1 = 1000, 800, 640, 512; c2 = blank, 800, 640, 512; SUM(ALL("cohort")) at 2026-03 = 1280. Age-dependent rates: an ages table with ids 1, 2, … and LOOKUP("rate","ages","id",MIN(age, 4)); numeric-looking ids are numbers (§7).

5.5 Financial statements from Financial Modeling Prep (hosted only)

Hosted only. finicast_import_fmp {ticker, statements:["income","balance","cashflow"], period:"annual"|"quarter", forecastPeriods?} creates periods (Frame = hist, plus fcst periods on request), accounts, the fact table financials (account, period, amount) and a pivot statement with the frame = hist method SUM(SELECT("amount","financials","account","=",THIS("account"),"period","=",THIS("period"))). Add frame = fcst methods as in 5.1; verify one rendered total against the filing.

6. Verification

7. Pitfalls

8. API cheat sheet

MCP tool REST (relative to the database base URL)
finidb_apply POST /apply (the model script; dryRun, prune)
finidb_describe GET /models, GET /models/{m}/tables/{t}
finidb_commands POST /commands (batch, atomic)
finidb_create_table / finidb_create_pivot POST /models/{m}/tables, POST …/tables/{t}/dims, PUT …/tables/{t}/pivot, POST …/tables/{t}/linked-pdims
finidb_add_method / finidb_set_values POST …/tables/{t}/methods / PUT …/tables/{t}/cells
finidb_import_csv POST /import/csv?modelId=&tableId=
finidb_render POST …/tables/{t}/render; POST /render (multi-table)
finidb_query no REST route: a scratch method in one POST /commands batch
finidb_explain POST …/tables/{t}/cells/explain `{dimId, recordId
finidb_parse_formula POST /formulas/parse
finidb_list_errors GET /errors
finidb_export GET /export/xlsx, GET /export/csv?tableId=, GET /export/script
finidb_stats GET /stats
finidb_guide / finidb_functions /guide / /docs/functions on finicast.com (resources finidb://guide, finidb://functions)
change feed GET /events?tables=m1:pl (SSE)

Every command that names a row (SET_VALUE(S), UNSET_VALUE, UPDATE_RECORD, REMOVE_RECORD, MOVE_RECORD, explain) takes exactly one of recordId, recordIdx or, for a pivot cell, coords; an unknown id names the table and the nearest existing id.

Run locally

npm install -g finidb
finidb serve --data ~/finidb-data --port 7407     # daemon; superuser password printed once
finidb createdb salesops --owner alice
finidb mcp --url http://localhost:7407 --db salesops --user alice

Embedded mode needs none of this: npx finidb mcp --data ./model or the CLI path in §2.

Benchmarks

Engine-time targets from the benchmark suite (finidb/bench, B1: 100,000 activity rows, 20 × 12 territory pivot): one input change ≤ 5 ms (expected < 1 ms); a change in scoring touching ~16,700 rows ≤ 25 ms; editing a pivot formula ≤ 100 ms; initial load with the 100,000-row import ≤ 1,500 ms; pivot page render ≤ 3 ms. Every cell is checked against a brute-force recomputation after each step. Numbers on finicast.com are these results, nothing else.