Tahanot

A mini-ERP whose assistant can only use the front door

Tahanot is a self-hosted mini-ERP inspired by Odoo’s ideas and rebuilt from scratch: sales, purchasing, inventory, invoicing and a dashboard for a small multi-company trading business. Its AI assistant answers questions about the business by calling the same services, with the same permission checks, as the rest of the app. It never writes SQL.

Type
Personal project, 2026, designed and built end to end
Modules
Sales, purchasing, inventory with backorders, invoicing and payments, dashboard
Assistant
Hand-written tool loop, 10 typed tools, up to 8 rounds
Data
PostgreSQL 16, SQLAlchemy 2, Alembic migrations
Tests
220 backend and 26 frontend test functions

A working prototype, version 0.1. Purchasing, credit notes and the French interface live on a feature branch that isn’t merged yet.

One question to the assistant

The assistant is one model in a loop with typed tools. Everything it can see goes through the same service layer and access checks as the REST API, so it can’t know more than the person asking.

One stock question, from the side panel to the answer
User asks in the side panel Auth and context JWT, company, rights Rate limit 10 a minute Model any compatible API Typed tools validated by Pydantic Service layer PostgreSQL, same rights Result blocks tables, charts, KPIs Answer text + components User asks in the side panel Auth and context JWT, company, rights Rate limit 10 a minute Model any compatible API Typed tools validated by Pydantic Service layer PostgreSQL, same rights Result blocks tables, charts, KPIs Answer text + components
  • Input or output
  • LLM call
  • Code
  • Data store
  • Check or gate
  1. User “How many units of SKU-102 are left in Warehouse 1?” On a record page, the panel also sends the record being viewed.
  2. Auth and context The JWT and the company header resolve the user, the companies they may see, their groups and their rights per model. A company they can’t see is a 403.
  3. Rate limit 10 messages per user per minute. The conversation is loaded or created, and the message saved.
  4. Model The system prompt is rebuilt on every request: date, user, warehouses, currency, grounding rules and a field catalogue generated from the live model registry. The model asks for get_stock(sku="SKU-102", warehouse_id=1).
  5. Typed tools Arguments are validated by Pydantic, then the tool calls the stock service with the user’s own rights. Cost fields come back as “restricted” for users without access.
  6. Model The result comes back fenced as untrusted data, never an instruction. No more tool calls, so it answers.
  7. Result blocks Tool payloads become real interface components: a stock tile here; elsewhere tables that link to records, bar charts and KPI tiles that can be pinned to the dashboard.
  8. Answer The figure from the tool result, shown next to the text that explains it.

Other routes through the graph

  • Up to 8 rounds of tool calls; past that, the answer is marked as possibly partial.
  • A tool error goes back to the model as a result, not an exception, so it can explain and adapt.
  • Write tools (create a quotation, confirm an order, add a reordering rule) are off by default. When enabled, the first call returns a proposal and an HMAC token valid for 600 s; only a second call with the same payload executes it.
  • Provider error: the stream ends with an error event. There is no silent retry.

The ERP underneath

Odoo’s ideas, not its code: a generic model layer, documents with explicit states, and stock held as quantities per location.

  • A generic model registry

    One CRUD router with domain filters serves every model; business actions such as confirming an order or validating a delivery have routes of their own.

  • Documents as state machines

    Quotations, orders and deliveries move through explicit states.

  • Quant-based stock

    Stock is held as quantities per product and location, with product variants, and backorders when a delivery is only partly done.

  • Invoices and payments

    Invoice payments and credit notes, with concurrency and locking tests that run against PostgreSQL.

  • One aggregation primitive

    read_group powers the dashboard, the pivot views and the assistant alike.

  • Two companies, two languages

    Multi-company from the data model up, with an English interface and a French one in progress.

Engineering choices

  • The model never writes SQL

    It calls typed, validated tools over the same services and permission checks as the REST API.

  • Provider-neutral

    Messages use a neutral format and an OpenAI-compatible endpoint is the default, so Claude or a local server is a configuration change.

  • Allowlisted models, hidden fields

    21 models are visible to the assistant. Cost fields are restricted to four groups, and secret fields are never serialised.

  • Untrusted by default

    Tool results are wrapped as untrusted data, and the system prompt says such data is never an instruction. A test checks that an injected product name stays data.

  • Writes need a second step

    A write first returns a proposal and a token signed over its payload; a changed payload is rejected.

  • Answers as components

    Results render as tables, charts and tiles built from the tool payloads, not only as text.

Stack

Backend
Python 3.12, FastAPI, SQLAlchemy 2, Alembic, Pydantic, PyJWT, Server-Sent Events
Data
PostgreSQL 16
AI
hand-written tool loop, Anthropic and OpenAI SDKs
Frontend
React 18, TypeScript, TanStack Query and Table, Recharts, Tailwind CSS 4, Vite
Infra
Docker Compose, nginx
Quality
pytest, Vitest, Testing Library

Building something similar?

I’m happy to walk through the design, the trade-offs, or what I would change next.