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](https://github.com/OfinTech/ofinTech-Dask)** is our answer to that, and as of **v1.0.0 (27 Oc

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](https://github.com/OfinTech/ofinTech-Dask)** 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. This post is not a feature tour.
It's a walk through the engineering decisions inside the engine, the places where we think the design is interesting, and the places where we'd love your help. > **TL;DR for the impatient** > - Python engine (Starlette + SQLite), React/TypeScript UI, Electron shell. ~29k lines. > - Bring any model: OpenAI, Anthropic, Gemini, DeepSeek, Mistral, Groq, OpenRouter, xAI, Azure, or local via Ollama / LM Studio. > - Our own agent loop (no framework) so runs can pause for approval and resume after a restart. > - A *figure check* flags every number in an answer that doesn't appear in a tool result. > - A tiny formula language, parsed (no `eval`), compiled to Excel, and evaluated with Excel's own rules — verified cell-by-cell against LibreOffice. > - Tool selection per request: **54% smaller requests** on a simple question. > - 99 engine tests, a browser smoke test, UI in English, Arabic (RTL), Spanish and French. --- ## The architecture in one picture Enter fullscreen mode Exit fullscreen mode 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 │ └──────────────────────────────┘ └───────────────────────────────┘
**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. --- ## 1. Why we wrote our own agent loop 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: Enter fullscreen mode Exit fullscreen mode model call → tool calls → results → model call … until the model answers (max 16 steps)
Every tool declares an `effect`: Enter fullscreen mode Exit fullscreen mode python 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"
- `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. --- ## 2.
The figure check: catching numbers that came from nowhere 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: Enter fullscreen mode Exit fullscreen mode python 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]
A few details that took iteration: - The parser understands `1,234.5`, `(1,200)` as negative, the Unicode minus `−`, and suffixes like `k`, `m`, `bn`, `million`, `%`. - `k * 100` and `k / 100` let `0.153` in a tool result verify `15.3%` in the answer. - A 0.5% relative tolerance lets the model round `1,234,567` to `1.23m` without being flagged. 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.** --- ## 3.
A formula language that agrees with Excel 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: Enter fullscreen mode Exit fullscreen mode plaintext variance = actual - budget variance_pct = (actual - budget) / budget gross margin = [pl:Gross profit:amount] / [pl:Revenue:amount] same, dotted form = pl.total.amount
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: 1. **compiled** to an Excel formula with real cell references, or 2. **evaluated** in Python with Excel's semantics: blank as zero, division by zero shown blank, and — the fun one — rounding. Enter fullscreen mode Exit fullscreen mode python
return float(Decimal(f"{x:.15g}").quantize(q, rounding=ROUND_HALF_UP))
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. ### Maker and checker 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. --- ## 4. Shipping fewer tokens: tool selection and cache-friendly prompts 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: Enter fullscreen mode Exit fullscreen mode python CORE = {"finance", "knowledge", "memory", "skills", "meta", "mcp"}
TRIGGERS = { "files": ["workbook", "excel", "xlsx", …, "مصنف", "تقرير", …, "libro", "informe", …, "classeur", …], "accounting": ["invoice", "ledger", "p&l", …, "فاتورة", "دفتر", …], … }
- 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. --- ## 5. Memory you can audit Long-term memory is where assistants get creepy, so ours is conservative: - The assistant only **proposes** a fact (a materiality threshold, an account mapping, a close deadline). Nothing is saved until a person approves it. - Every entry shows its origin, who saved it and how often it was used; answers list which memories they consulted. - Secrets are refused by pattern: passwords (in four languages), API keys, IBANs, full account numbers, and card numbers validated with a Luhn check. --- ## 6.
Extending it 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). Enter fullscreen mode Exit fullscreen mode bash 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 "
**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. --- ## Run it in five minutes Enter fullscreen mode Exit fullscreen mode bash 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)
cd engine DESK_DEV=1 DESK_UI_DIR=../ui/dist DESK_LOCAL_TOKEN=$(python -c "print('x'*40)") python -m desk_engine
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`. Tests: Enter fullscreen mode Exit fullscreen mode bash 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