DEV Community

Chandan Sharma
Chandan Sharma

Posted on

Your AI can query Odoo. It still gets revenue wrong.

Connecting a large language model to Odoo used to be the hard part. It is not any more. There are MCP servers, XML-RPC wrappers, and JSON-RPC clients that hand an agent the ability to read and write Odoo records in an afternoon. Reaching the data is solved.

Understanding it is not. An agent that can run any query will still answer "what was our revenue last month" with a number that is confidently, quietly wrong. The failure is not in the connection. It is that the model was never told what the records mean.

Here are three traps I hit repeatedly, each one real, each one something a naive agent walks straight into.

Trap 1: "invoices" is not a table

Ask an agent to "add up last month's invoices" and it will look for something invoice-shaped. In Odoo it finds account.move, sees invoice-like columns, and sums them.

But account.move is not the invoices table. It is the journal-entries table. Customer invoices, vendor bills, customer credit notes, and vendor refunds all live in the same model, separated only by a discriminator column, move_type:

  • out_invoice is a customer invoice
  • in_invoice is a vendor bill
  • out_refund is a customer credit note
  • in_refund is a vendor refund

So the honest domain for "customer invoices" is:

[("move_type", "=", "out_invoice")]
Enter fullscreen mode Exit fullscreen mode

An agent that skips that filter sums your sales and your supplier bills into one figure. It will not error. It will return a plausible, wrong total. You can see the discriminator in Odoo's own source, in addons/account/models/account_move.py, where move_type is defined and used to tell these document types apart.

Trap 2: "revenue" has no single answer

Say the filter is right and the agent is looking only at customer invoices. Now: what is revenue?

There is general-ledger revenue, recognised through income accounts. There is invoiced sales, the total of issued customer invoices. They are different numbers, on different date bases (invoice date, accounting date, or delivery period), and a business means a specific one when it asks. No column in Odoo is named revenue. A field like amount_total looks close, but it is a document total that mixes tax and, across the wrong population of moves, mixes document types too.

The correct behaviour when a human asks "what is revenue" is not to answer. It is to ask which definition and which date basis apply, then compute that one. An agent optimised to be helpful will instead pick the first plausible field and commit.

Trap 3: field labels lie

account.move has a field invoice_user_id. From the name you might read "the user who created the invoice". It is not. It is the salesperson associated with the invoice, which is a business fact about attribution, not authorship. If your agent maps it to "created by" and builds a per-rep sales report on it, the report is subtly wrong in a way nobody catches until commission season.

Field names are shorthand written for developers, not contracts about meaning. Guessing from the label is how an agent produces answers that survive review because they look reasonable.

The missing layer

The pattern under all three traps is the same. A connector tells an agent how to reach a record. Nothing tells it what the record means, which tempting reading is wrong, or what it must clarify before it computes. That second thing is a separate layer:

question -> context -> AI reasoning + existing connector -> Odoo
Enter fullscreen mode Exit fullscreen mode

The context step carries three kinds of knowledge the connector never will: what a business noun maps to, what a field actually means, and the negative knowledge of which plausible interpretation to refuse. I have been building an open format for exactly this layer, OCL, and the reference implementation is small enough to run in a minute, so the rest of this is a walk-through you can reproduce.

Trying it

The repository ships an example resolver over ten public Odoo 19 example entries. Clone it and ask the revenue question:

git clone https://github.com/Nantiai/ocl-standard.git
cd ocl-standard
python reference/python/ocl_examples.py get-context "What is revenue?"
Enter fullscreen mode Exit fullscreen mode

The interesting part of the response, trimmed:

{
  "facts": [
    {
      "entry_id": "ocl.example.odoo19.account.revenue_ambiguity",
      "rendered": "... Clarify: Which business definition and date basis apply?"
    }
  ],
  "warnings": [
    {
      "entry_id": "ocl.example.odoo19.account.amount_total_warning",
      "rendered": "... Correction: Resolve the metric definition and record population first.",
      "severity": "warning"
    }
  ],
  "unknowns": [
    {
      "code": "clarify_revenue",
      "message": "Which business definition and date basis apply?",
      "required_action": "clarify_before_execution"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The context pack does not answer the question. It returns a warning against treating one total field as universal revenue, and an unknown that says clarify before execution. Handed to a model as tool output, that is the difference between an agent that guesses and one that asks the one question it should have asked.

The other two traps are covered by two more calls. Resolving a noun to its real domain:

python reference/python/ocl_examples.py resolve-noun "customer invoice"
# -> model account.move, domain [["move_type", "=", "out_invoice"]]
Enter fullscreen mode Exit fullscreen mode

And explaining a field instead of trusting its name:

python reference/python/ocl_examples.py explain-field account.move invoice_user_id
# -> meaning: "Salesperson associated with the invoice."
Enter fullscreen mode Exit fullscreen mode

Putting it in an agent's tool loop

The same resolver runs as an MCP server, so an agent can call get_context, resolve_noun, and explain_field mid-reasoning:

{
  "mcpServers": {
    "ocl-public-examples": {
      "command": "python",
      "args": [
        "/absolute/path/to/ocl-standard/reference/python/ocl_examples.py",
        "serve-mcp"
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, before the agent writes a query, it can ask what "revenue" means, get told to clarify, and clarify. Before it reports on a rep, it can check what invoice_user_id is. The knowledge lives outside the prompt, so it is auditable and it does not drift.

Honest boundaries

This is worth saying plainly, because the failure mode of posts like this is overclaiming.

  • The format is an experimental technical alpha, 0.1.0. It can change while independent implementations test it.
  • The ten examples are candidates that demonstrate the shape, not a certified registry. Each one carries a low confidence score on purpose. A conforming document describes evidence and assertions; deciding a fact is verified is a separate, deliberate step, not "an LLM wrote plausible JSON".
  • The example server serves only those public examples. It is not a commercial runtime, and cloning it does not give you coverage of a real Odoo database.

What is real and reproducible today is the pattern: separate reaching records from meaning, encode the meaning as facts, warnings, and unknowns, and give an agent a way to ask before it computes. You can apply that idea with or without this repo.

Who I am

I help build nanti.ai, and OCL is our open take on this context layer for Odoo. The repository is Apache-2.0: github.com/Nantiai/ocl-standard. If you would rather see one grounded decision run without cloning anything, there is a no-login live demo of the revenue case at api.context.nanti.ai/demo/revenue. The reference server is also published on the official MCP registry as io.github.Nantiai/ocl-standard.

Odoo is a trademark of Odoo S.A. This project is independently developed and is not affiliated with or endorsed by Odoo S.A.

If you have connected an agent to Odoo, I would like to know which field it got wrong first. Mine was invoice_user_id.

Top comments (0)