<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Chandan Sharma</title>
    <description>The latest articles on DEV Community by Chandan Sharma (@chandsharma).</description>
    <link>https://dev.to/chandsharma</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4113414%2F3dade9d8-032f-4456-87c6-ced33485bb3b.jpg</url>
      <title>DEV Community: Chandan Sharma</title>
      <link>https://dev.to/chandsharma</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chandsharma"/>
    <language>en</language>
    <item>
      <title>Your AI can query Odoo. It still gets revenue wrong.</title>
      <dc:creator>Chandan Sharma</dc:creator>
      <pubDate>Mon, 07 Sep 2026 19:20:30 +0000</pubDate>
      <link>https://dev.to/chandsharma/your-ai-can-query-odoo-it-still-gets-revenue-wrong-223d</link>
      <guid>https://dev.to/chandsharma/your-ai-can-query-odoo-it-still-gets-revenue-wrong-223d</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Here are three traps I hit repeatedly, each one real, each one something a naive agent walks straight into.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: "invoices" is not a table
&lt;/h2&gt;

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

&lt;p&gt;But &lt;code&gt;account.move&lt;/code&gt; 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, &lt;code&gt;move_type&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;out_invoice&lt;/code&gt; is a customer invoice&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;in_invoice&lt;/code&gt; is a vendor bill&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;out_refund&lt;/code&gt; is a customer credit note&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;in_refund&lt;/code&gt; is a vendor refund&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the honest domain for "customer invoices" is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;move_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out_invoice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;addons/account/models/account_move.py&lt;/code&gt;, where &lt;code&gt;move_type&lt;/code&gt; is defined and used to tell these document types apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: "revenue" has no single answer
&lt;/h2&gt;

&lt;p&gt;Say the filter is right and the agent is looking only at customer invoices. Now: what is revenue?&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;revenue&lt;/code&gt;. A field like &lt;code&gt;amount_total&lt;/code&gt; looks close, but it is a document total that mixes tax and, across the wrong population of moves, mixes document types too.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: field labels lie
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;account.move&lt;/code&gt; has a field &lt;code&gt;invoice_user_id&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The missing layer
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;question -&amp;gt; context -&amp;gt; AI reasoning + existing connector -&amp;gt; Odoo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trying it
&lt;/h2&gt;

&lt;p&gt;The repository ships an example resolver over ten public Odoo 19 example entries. Clone it and ask the revenue question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Nantiai/ocl-standard.git
&lt;span class="nb"&gt;cd &lt;/span&gt;ocl-standard
python reference/python/ocl_examples.py get-context &lt;span class="s2"&gt;"What is revenue?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part of the response, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"facts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"entry_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ocl.example.odoo19.account.revenue_ambiguity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rendered"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"... Clarify: Which business definition and date basis apply?"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"warnings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"entry_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ocl.example.odoo19.account.amount_total_warning"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rendered"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"... Correction: Resolve the metric definition and record population first."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"warning"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"unknowns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"clarify_revenue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Which business definition and date basis apply?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"required_action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"clarify_before_execution"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The context pack does not answer the question. It returns a &lt;code&gt;warning&lt;/code&gt; against treating one total field as universal revenue, and an &lt;code&gt;unknown&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The other two traps are covered by two more calls. Resolving a noun to its real domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python reference/python/ocl_examples.py resolve-noun &lt;span class="s2"&gt;"customer invoice"&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; model account.move, domain [["move_type", "=", "out_invoice"]]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And explaining a field instead of trusting its name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python reference/python/ocl_examples.py explain-field account.move invoice_user_id
&lt;span class="c"&gt;# -&amp;gt; meaning: "Salesperson associated with the invoice."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Putting it in an agent's tool loop
&lt;/h2&gt;

&lt;p&gt;The same resolver runs as an MCP server, so an agent can call &lt;code&gt;get_context&lt;/code&gt;, &lt;code&gt;resolve_noun&lt;/code&gt;, and &lt;code&gt;explain_field&lt;/code&gt; mid-reasoning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ocl-public-examples"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"/absolute/path/to/ocl-standard/reference/python/ocl_examples.py"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"serve-mcp"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;invoice_user_id&lt;/code&gt; is. The knowledge lives outside the prompt, so it is auditable and it does not drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest boundaries
&lt;/h2&gt;

&lt;p&gt;This is worth saying plainly, because the failure mode of posts like this is overclaiming.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The format is an experimental technical alpha, &lt;code&gt;0.1.0&lt;/code&gt;. It can change while independent implementations test it.&lt;/li&gt;
&lt;li&gt;The ten examples are &lt;strong&gt;candidates that demonstrate the shape&lt;/strong&gt;, 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".&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What is real and reproducible today is the pattern: separate &lt;em&gt;reaching&lt;/em&gt; records from &lt;em&gt;meaning&lt;/em&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who I am
&lt;/h2&gt;

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

&lt;p&gt;Odoo is a trademark of Odoo S.A. This project is independently developed and is not affiliated with or endorsed by Odoo S.A.&lt;/p&gt;

&lt;p&gt;If you have connected an agent to Odoo, I would like to know which field it got wrong first. Mine was &lt;code&gt;invoice_user_id&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>odoo</category>
      <category>ai</category>
      <category>mcp</category>
      <category>python</category>
    </item>
  </channel>
</rss>
