Odoo Function Calling: Giving an LLM ERP Tools
Turn ODXProxy's nine Odoo actions into function-calling tools an LLM can invoke. Tool schemas, argument mapping, and guarding writes — with runnable JSON.
ODXProxy Team · Jul 5, 2026 · 7 min read

You want an assistant that can answer "how many open quotations does Azure Interior have?" by actually querying Odoo — not by guessing. That's function calling: you describe a set of tools to a large language model, the model picks one and fills in the arguments, your code runs it, and the result goes back to the model. This guide shows how to expose ODXProxy's nine Odoo actions as function-calling tools for an LLM, how to map the model's tool arguments onto the proxy's JSON-RPC request shape, and how to guard the write paths so an eager model can't do damage. It pairs closely with the Odoo MCP server approach — function calling is the same idea done directly in your own agent loop instead of behind a protocol.
Function calling in one paragraph
Every modern LLM tool API works the same way. You pass a list of tool definitions — each a name, a
description, and a JSON Schema for the inputs. The model, instead of answering in prose, may respond
with a tool-use request: "call odoo_search_read with these arguments." Your code executes the
call, then sends the result back as a tool result and asks the model to continue. It's a loop, and
it runs until the model has what it needs to answer. The examples below use Anthropic's Claude tool-use
format, but the shape is nearly identical for OpenAI-style function calling — a name, a
description, and a JSON Schema.
The nine actions become tools
ODXProxy exposes nine actions: search_count, search, read, fields_get, search_read, create,
write, unlink, and call_method. You don't have to expose all nine. A good starting surface for a
read-mostly assistant is search_read (find and read in one call), fields_get (let the model
discover a model's fields), and call_method for the occasional business action. Add create/write/
unlink only once you've decided how to guard them (see below).
Here's a search_read tool defined for Claude. The description is written for the model — it tells it
when to reach for the tool, not just what it does:
tools = [
{
"name": "odoo_search_read",
"description": (
"Search Odoo records matching a domain filter and read back selected "
"fields in one round trip. Call this whenever the user asks to look up, "
"list, count, or filter records such as customers, sale orders, or products."
),
"input_schema": {
"type": "object",
"properties": {
"model": {"type": "string", "description": "Odoo model, e.g. res.partner"},
"domain": {
"type": "array",
"description": "Odoo search domain, e.g. a filter on is_company being true",
},
"fields": {"type": "array", "items": {"type": "string"}},
"limit": {"type": "integer"},
},
"required": ["model", "domain", "fields"],
},
}
]Send it on a normal request. When the model wants the tool, the response comes back with
stop_reason: "tool_use" and a tool-use block carrying the arguments:
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
tools=tools,
messages=[{"role": "user", "content": "List the email addresses of our company customers."}],
)From tool call to proxy request
The model returns intent — a model name, a domain, some fields. Your handler turns that into ODXProxy's
wire shape and posts it. The one thing to get right is the argument packing: params is a JSON array
of positional arguments and keyword is a JSON object, so the domain nests one level inside
params while fields and limit go in keyword:
import requests
def run_search_read(args, proxy_url, proxy_key, instance):
payload = {
"id": "fc-1",
"action": "search_read",
"model_id": args["model"],
"params": [[args["domain"]]], # domain is the first positional arg
"keyword": {
"fields": args["fields"],
"limit": args.get("limit", 50),
},
"odoo_instance": instance, # url, db, user_id, api_key — never from the model
}
return requests.post(
f"{proxy_url}/api/odoo/execute",
headers={"x-api-key": proxy_key, "Content-Type": "application/json"},
json=payload,
)The resulting HTTP request is an ordinary ODXProxy call — nothing LLM-specific about it:
{
"id": "fc-1",
"action": "search_read",
"model_id": "res.partner",
"params": [[["is_company", "=", true]]],
"keyword": { "fields": ["name", "email"], "limit": 50 },
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}Two credentials are in play, and they are not the same thing: the proxy's x-api-key header
authenticates your code to ODXProxy, while odoo_instance.api_key is the Odoo user's key that the
proxy uses to talk to Odoo. Both live in your handler's configuration. The model only ever produces the
tool arguments — it never sees, and never needs, either secret. The details of the domain syntax you're
passing through are in Odoo domain filters explained.
Handing the result back — and the HTTP 200 trap
Once the call returns, you feed the result to the model as a tool result and let it continue. But before
you do, apply the check that trips up every Odoo integration: an HTTP 200 can still carry an error.
Odoo's own logic errors — access-right denials, validation rules, a UserError — return 200 with a
populated error object; only proxy-layer problems (bad key, unknown action, timeout) use non-200
status codes.
Do the two-step check, then hand the model either the data or the error text so it can react:
resp = run_search_read(args, proxy_url, proxy_key, instance)
body = resp.json()
if resp.status_code != 200:
tool_result = {"error": f"proxy failure: {body.get('error')}"} # -32000, -32001, timeout, etc.
elif "error" in body:
tool_result = {"error": body["error"]["message"]} # Odoo logic error on a 200
else:
tool_result = {"records": body["result"]}Returning the error string (rather than swallowing it) is what lets the model recover gracefully —
"that user can't read account.move, let me try a different approach" instead of hallucinating a
result. The complete, reusable two-layer handler lives in
Odoo API error handling.
Guarding the write paths
Reads are low-stakes; a bad search_read just returns nothing useful. Writes are a different matter — a
model that misreads a request could unlink the wrong records or post an invoice early. Three guards,
in increasing order of safety:
- Don't expose what you don't need. If the assistant is read-only, simply don't include
create,write,unlink, orcall_methodin the tool list. The proxy's allowlist bounds the actions; your tool list bounds what the model can even attempt. - Confirm before mutating. Keep write tools in the list, but in your handler, pause on
create/write/unlink/call_methodand require a human to approve the exact payload before it's posted. Because you control the loop, this is just anifbefore therequests.post. - Use a least-privileged Odoo user. The
odoo_instance.api_keyyou configure carries that Odoo user's access rights. Point the assistant at a user scoped to only the models it should touch, and Odoo itself enforces the boundary — returning a logic error (on a200) if the model reaches too far.
MCP or roll your own?
Both this article and the Odoo MCP server expose the same nine actions to an LLM. The difference is packaging. Direct function calling, as shown here, keeps the whole loop in your own code — best when you're building one assistant and want full control over guards and error handling. An MCP server standardizes the tool layer so any MCP-aware client (Claude Desktop, an IDE, another team's agent) can reuse it — best when the tools are a shared capability, not a single app. Same wire protocol underneath; pick by how many callers you expect.
Where to go next
Function calling turns ODXProxy's nine actions into a toolbox an LLM can operate: describe each action
as a tool, map the model's arguments onto params and keyword, run the two-step error check, and
guard the writes.
- Standardize the same tools behind a protocol with the Odoo MCP server.
- Reach past CRUD into business logic with calling custom Odoo methods with call_method.
- Start at the beginning with authenticating to the Odoo API.
- See the request envelope and the nine actions in the API reference, and let the Python SDK handle the JSON-RPC envelope inside your tool handlers.