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.
- Input or output
- LLM call
- Code
- Data store
- Check or gate
- User “How many units of SKU-102 are left in Warehouse 1?” On a record page, the panel also sends the record being viewed.
- 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.
- Rate limit 10 messages per user per minute. The conversation is loaded or created, and the message saved.
- 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).
- 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.
- Model The result comes back fenced as untrusted data, never an instruction. No more tool calls, so it answers.
- 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.
- Answer The figure from the tool result, shown next to the text that explains it.
This request does not pass through here. The other routes are listed below.
That is the whole request. Want something like this running on your own data?
Select any box in the diagram, or follow the request one step at a time.
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.