OfinTech Desk: an open-source AI workspace for finance where every number has a source
Most "AI for finance" demos have the same flaw: the model writes a confident paragraph full of numbers, and nobody can tell which of them came from the ledger and which the model made up. OfinTech Desk is our answer to that, and as of v1.0.0 (27 Oct 2026) it is fully open source under Apache-2.0 .

Most "AI for finance" demos have the same flaw: the model writes a confident paragraph full of numbers, and nobody can tell which of them came from the ledger and which the model made up.
OfinTech Desk is our answer to that, and as of v1.0.0 (27 Oct 2026) it is fully open source under Apache-2.0. It's a desktop app (plus an optional self-hosted Team Server) that reads your ledger, spreadsheets, documents and inbox, does the arithmetic in code, fills in your own Excel and Word files with a source for every figure, and asks a human before it sends or changes anything.
Desktop app (Electron) Team Server (Docker) ┌───────────────────────────────┐ ┌──────────────────────────────┐ │ Renderer: React UI (sandboxed)│ │ Caddy (HTTPS) ─► engine │ │ ▲ token via preload bridge │ or, in team mode │ server mode, accounts, │ │ Main: starts engine, keeps │ ─────────────────► │ invitations, webhooks │ │ master key in OS storage │ loads server UI │ (WhatsApp, Telegram) │ │ Engine (Python) on 127.0.0.1 │ └──────────────────────────────┘ └───────────────────────────────┘ Enter fullscreen mode Exit fullscreen mode One engine, two modes. The same Python engine runs as a single-user sidecar on loopback inside the desktop app, or as a multi-user server behind Caddy. In desktop mode it creates a master key, stores it in the OS keychain (Keychain / DPAPI / libsecret), generates a fresh access token on every launch, and rejects foreign Host headers to defeat DNS rebinding.
SQLite everywhere. WAL mode, one connection per thread, serialised writes, FTS5 for search over documents, conversations and memory. It needs zero administration on a laptop and is plenty for a team-sized server. (PostgreSQL is on the roadmap — see "Where you can help".)
Few, permissive dependencies. Starlette, uvicorn, pydantic, httpx, mcp, openpyxl, cryptography, PyYAML. Charts, the Markdown renderer and the workflow editor are written in the project, so there's no runtime CDN or font download.
We looked at agent frameworks and kept bumping into one requirement: a run must be able to stop in the middle, wait hours for a human, and resume — even after the process restarts.
So the loop in engine/desk_engine/agent/harness.py is deliberately boring:
model call → tool calls → results → model call … until the model answers (max 16 steps) Enter fullscreen mode Exit fullscreen mode Every tool declares an effect:
Effect = Literal [ " read ", " write " ] def needs_approval ( self ) -> bool: if self. policy is not None: # MCP tools: auto | ask | off return self. policy!= " auto " return self. effect == " write " Enter fullscreen mode Exit fullscreen mode read tools (calculators, queries, document search) run immediately. write tools (send an e-mail, post a Telegram message, run a workflow) don't run at all. The loop records an approval with the exact arguments, persists the pending calls into the run's state, sets the run to waiting_approval, and returns. When an editor approves or rejects in the inbox, _resolve_pending replays the stored calls.
A rejection is fed back to the model as a tool result — "The user rejected this action. Do not retry it unless they ask; offer an alternative." — so the conversation keeps going instead of dead-ending.
Because state lives in SQLite rather than in a Python coroutine, a restart is just another resume. The loop speaks two wire protocols — OpenAI Chat Completions and Anthropic Messages — which together cover every provider we list.
The model is treated as untrusted input. It never gets SQL, a shell, or code execution. Its output is rendered from Markdown into React elements, never as HTML. Tools return text for the model plus typed blocks ( table, kpis, link, skill ) that the UI draws itself.
After the final answer, the harness collects every tool result, every user message and every consulted memory as evidence, then extracts all numbers from the answer and checks each one against it:
def unverified_figures ( answer: str, evidence: list [ str ]) -> list [ str ]: known = [ v for text in evidence for _, v in _numbers ( text )] flagged = [] for raw, v in _numbers ( answer ): a = abs ( v ) if a < 100 and ". " not in raw and " % " not in raw: continue # small counts, list numbers, days if 1900 <= a <= 2100 and ". " not in raw and ", " not in raw: continue # years ok = any ( abs ( abs ( cand ) - a ) <= max ( abs ( cand ) * 0.005, 0.011 ) for k in known for cand in ( k, k * 100, k / 100 )) if not ok and raw not in flagged: flagged. append ( raw ) return flagged [: 20 ] Enter fullscreen mode Exit fullscreen mode A few details that took iteration:
It's a heuristic, not a proof — and we say so in the UI. But it turns "trust me" into a short list a reviewer can actually check. Ideas for making it smarter (units, currencies, derived figures) are very welcome.
When the assistant builds a workbook, computed columns and totals aren't pasted values — they're live Excel formulas. That raises a hard problem: the value we show in the app must equal what Excel computes when you open the file.
So engine/desk_engine/files/expr.py implements a tiny language:
variance = actual - budget variance_pct = (actual - budget) / budget gross margin = [pl:Gross profit:amount] / [pl:Revenue:amount] same, dotted form = pl.total.amount Enter fullscreen mode Exit fullscreen mode Only ABS, ROUND, MIN, MAX, SUM, AVERAGE, the four operators and parentheses. Each expression is tokenised and parsed into a small AST of frozen dataclasses ( Num, Col, Ref, Neg, Bin, Call, Span ). That single AST is then either:
# ROUND(7250.5 * 1.15, 2) is 8338.08 as in Excel, not 8338.07. return float ( Decimal ( f " { x:. 15 g } " ). quantize ( q, rounding = ROUND_HALF_UP )) Enter fullscreen mode Exit fullscreen mode Excel rounds half away from zero at 15 significant digits; naive float rounding disagrees on values like this one. No eval anywhere.
The test suite then does something we're quite proud of: every formula cell in a generated workbook is recalculated by LibreOffice and compared with the engine's value. If the two disagree, the build fails.
The same "resolve once" idea applies to the whole file: a definition is resolved into a model where every cell has value, number format, live formula, readable formula, source and address. The Excel writer, the Word writer, the in-app viewer and the reviewer all read that one model, so they can't disagree.
Every generated file is annotated by an independent reviewer: a separate model call with a fresh context containing only the file, its sources and read-only tools. It never sees the drafting conversation, so it can't be talked into agreeing with it. Findings land as notes on specific cells and a Review sheet.
An agent re-sends its whole context on every step, and tool schemas are the biggest chunk (~32,000 characters for all built-in tools). agent/context.py keeps requests small:
CORE = { " finance ", " knowledge ", " memory ", " skills ", " meta ", " mcp " } TRIGGERS = { " files ": [ " workbook ", " excel ", " xlsx ", …, " مصنف ", " تقرير ", …, " libro ", " informe ", …, " classeur ", … ], " accounting ": [ " invoice ", " ledger ", " p&l ", …, " فاتورة ", " دفتر ", … ], … } Enter fullscreen mode Exit fullscreen mode Every request carries the core groups, plus groups whose trigger words appear in the last three user messages (in four languages ), plus groups already used in the thread. If the model needs something that wasn't sent, it calls more_tools and gets the group on the next step. A tool that wasn't sent still runs if called by name — selection never blocks work.
Tools are sorted by (category, name), and the system prompt is split into a stable prefix and a small changing part (date, language, memories for this message), so provider prompt caches stay valid. Anthropic cache breakpoints are set after the tools, after the stable system block, and on the latest message. Tool results older than two turns are cut to 600 characters in what is resent — never in what is stored, so they still count as evidence for the figure check.
Result, reproduced by tests/test_tokens.py: a simple calculation question went from 32,783 to 14,973 characters (54% smaller), 27 tools to 16. Set DESK_TOOL_SELECTION=off if a model misbehaves.
Long-term memory is where assistants get creepy, so ours is conservative:
There are four well-defined extension points, and each is a good first contribution:
Skills — plain SKILL.md files with YAML front matter. They're instructions only; nothing in them is executed. Fifteen built-in finance skills ship today (month-end close, bank reconciliation, 13-week cash forecast, IFRS 15 / ASC 606…). If you know a finance process well, you can contribute without writing Python.
The Open Accounting Connector — QuickBooks, Xero, Zoho Books and Odoo are built in. Any other ledger plugs in through an OpenAPI 3.1 contract with five endpoints, in pull mode (a reference adapter in examples/accounting-adapter/adapter.py, standard library only) or push mode (an ingest API with a hashed key).
ADAPTER_KEY = change-me python examples/accounting-adapter/adapter.py curl -H "Authorization: Bearer change-me" \ "http://127.0.0.1:8787/v1/profit-and-loss?start=2026-01-01&end=2026-03-31" Enter fullscreen mode Exit fullscreen mode MCP, both directions — connect external MCP servers as agent tools with an ask/auto/off policy per tool (and auto is downgraded for write tools), or expose the workspace as a read-only MCP server at /mcp.
Languages — UI strings are written in English inside tr("…") and translated in ui/src/i18n/{ar,es,fr}.ts. npm run i18n:check reports coverage (currently 899/899). Arabic flips the layout with logical CSS properties.
git clone https://github.com/OfinTech/ofinTech-Dask.git && cd ofinTech-Dask pip install -r engine/requirements.txt ( cd ui && npm install && node build.mjs ) # engine + UI in a browser cd engine DESK_DEV = 1 DESK_UI_DIR =../ui/dist DESK_LOCAL_TOKEN = $( python -c "print('x'*40)" ) python -m desk_engine # open http://localhost:47821/#token=xxxxxxxx… (same token) Enter fullscreen mode Exit fullscreen mode Point it at a local model through Ollama if you'd rather not use an API key. For the full desktop app: (cd desktop && npm install && npm start). For a team: cd server && cp.env.example.env && docker compose up -d --build.
cd engine && python -m unittest discover -s tests -t. # 99 tests, ~11 s cd ui && npx tsc --noEmit && npm run i18n:check python e2e/ui_smoke.py # real UI in Chromium + a fake model Enter fullscreen mode Exit fullscreen mode External services are replaced by an HTTP mock transport shaped like each provider's documented responses; the MCP tests run a real MCP server process.
We think honesty about limits is part of open source, so the README has a section for it:
Here's what's on the roadmap and where a contributor could make a real dent:
The contribution rules are short and opinionated, because the project handles other people's money: no secrets or real data in fixtures, no vendor lock-in, the model is untrusted, no compliance claims, and every figure stays traceable. Changes touching auth, permissions, encryption, approvals or agent tools get a second reviewer.
⭐ Repo: https://github.com/OfinTech/ofinTech-Dask 📄 Docs: docs/ARCHITECTURE.md, docs/SECURITY.md, docs/COSTS.md, docs/FILES.md 🤝 Contributing: CONTRIBUTING.md · Security reports: SECURITY.md
If you've built agents that touch money, I'd especially like to hear how you handle the "where did this number come from?" problem. Drop a comment, open an issue, or send a PR.