DEV Community

Cover image for OfinTech Desk: an open-source AI workspace for finance where every number has a source
Mohamed Ibrahim
Mohamed Ibrahim

Posted on

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

 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.


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:

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.


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:

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:

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

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:

  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.
# ROUND(7250.5 * 1.15, 2) is 8338.08 as in Excel, not 8338.07.
return float(Decimal(f"{x:.15g}").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.

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:

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.


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

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.


Run it in five minutes

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.

Tests:

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.


What we don't claim

We think honesty about limits is part of open source, so the README has a section for it:

  • Tax and accounting output is general guidance. No certification or compliance is claimed (ZATCA, PDPL, SOC 2, SAMA or anyone else).
  • Accounting connectors are read-only. There is no live bank feed and no live FX feed.
  • The reviewer is a second model check, not an audit.
  • Connectors were tested against mocks of documented APIs, not live accounts.

Where you can help

Here's what's on the roadmap and where a contributor could make a real dent:

  • 🐘 PostgreSQL support for larger Team Servers.
  • 🔌 New connectors: Sage, NetSuite, FreshBooks, MYOB — or an adapter for your ERP via the Open Accounting Connector.
  • ✍️ Write-back to accounting systems (draft bills and journals, behind approvals).
  • 🔐 SAML SSO and TOTP.
  • 📊 Keeping charts and images when editing a user's own workbook; scheduled dashboard e-mails and PDF export.
  • 🌍 Translations: native-speaker review of Arabic, Spanish and French, or a new language.
  • 🧪 Live testing: try the connectors against a QuickBooks sandbox, a Xero demo company or an Odoo trial and tell us what breaks.
  • 📚 Skills: write a SKILL.md for a finance process you know.

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.

Top comments (0)