ODXPilot
How ODXPilot is designed to run Odoo administration with AI safely, and the best practices for any AI agent on Odoo, with ODXProxy examples.
Request a demo
ODXPilot is shown by demo: contact us to see it. It is not released and does no Odoo work yet. Milestone 1 runs: a chat pipeline (an orchestrator that streams replies, an MCP server, a terminal client) with Claude and Gemini on Google Vertex AI behind one router. Everything else on this page is how it is designed to work. Availability and pricing aren't decided.
ODXPilot is an AI assistant for day-to-day Odoo administration. Your team asks in plain language; ODXPilot prepares the work in Odoo as a plan of drafts, checks it against your roles and procedures, and waits for a person to approve. See the ODXPilot overview for the why.
Where it fits
ODXPilot is a client of ODXProxy, like any app built on the SDKs. It reaches Odoo only through ODXProxy. It uses ODXBrain to find which Odoo model holds the data and which of your SOPs applies, and fetches that SOP from ODXDocs. ODXProxy itself is unchanged: no roles, users or policies are added to it.
Your people ── sign in with your identity provider
│
▼
ODXPilot orchestrator: understand → SOP → plan → policy dry-run → approve
│ MCP server: policy engine · masking · company context · audit
│
├── ODXBrain which Odoo model? → account.move, …
│ which of our SOPs? → vendor-bill-from-po @ a41c07e2…
│
├── ODXDocs that SOP, that exact version → business rules + technical notes
│
└── ODXProxy fields_get · search_read · create / write (drafts)
│
▼
Odoo still the engine and the gatekeeperOne stack per Odoo instance: one ODXPilot, one ODXProxy and one ODXBrain (with
ODXDocs) serve
exactly one Odoo database, with every company in it. The instance's url and db are
fixed in the stack's configuration, so the agent can't be steered to another Odoo.
Stacks are never shared between customers.
The model plans, code decides
| The model | ODXPilot (deterministic) | |
|---|---|---|
| Understands the request | yes | |
| Picks the procedure (SOP) | describes the task | maps it to one published SOP version filtered to this instance, or stops |
| Writes the plan | yes | every model / field must exist in live fields_get |
| Decides permissions | never | role policy, default deny |
| Sees masked fields | never | masked before the data reaches the model |
| Posts, pays, reconciles | never on its own | only after approval by the right role |
Tool tiers
| Tier | Odoo calls | Rule |
|---|---|---|
| Read | search_read, read, search_count, fields_get | run freely, within the role |
| Write | create, write | the person approves the plan |
| Irreversible | posting (action_post), payments, reconciliation, unlink | explicit approval per action; unlink on accounting records is never available |
People, roles and connections
Odoo is licensed per user, so teams often share Odoo users. ODXPilot therefore keeps people and Odoo users apart:
- People sign in to ODXPilot through your identity provider (OIDC or SAML; AD / LDAP through the identity broker; built-in email + MFA for teams without one).
- Roles live in ODXPilot, per (person, company): assigned by your admin or mapped from IdP groups. Odoo groups are not used for roles.
- Connections are Odoo users plus their API keys, registered by your admin. Each role is bound to a connection; many people can share one. Keys are write-only: once entered, nobody sees them again.
Every action is still recorded against the person: in ODXPilot's audit log and in a chatter note on the Odoo record, e.g. "Created by Rina (accountant) via ODXPilot, approved by Budi".
Preset roles
| Role | Can do |
|---|---|
auditor | read accounting data, bank numbers masked, no writes |
billing_clerk | draft customer invoices and vendor bills, edit partners (not bank fields) |
accountant | + draft journal entries, reconciliation, view bank statements; no bank changes, no posting payments, no chart of accounts / taxes / lock dates |
finance_manager | post, register payments, approve bank account changes, set lock dates; still bound by segregation of duties |
tenant_admin | manages users, roles, connections, policies and SOPs; no accounting rights unless also given a finance role |
Never through the agent, for any role: managing Odoo users and groups, or deleting accounting records.
Policy format (draft)
Policies name exact technical names and are matched deterministically. A rule that names a model, field or method missing from the instance fails closed.
role: accountant
inherits: billing_clerk
default: deny
rules:
- model: account.move
actions: [read, create, write]
when: { state: draft }
- model: account.move
methods: [action_post, button_cancel, button_draft]
effect: require_approval
approver_role: finance_manager
- model: res.partner.bank
actions: [read]
mask_fields: [acc_number]
- model: res.partner.bank
actions: [create, write, unlink]
effect: denySOPs with ODXDocs
ODXPilot doesn't improvise accounting. Every request is mapped to one of your own standard operating procedures (SOPs), written in ODXDocs, the SOP manager that comes with ODXBrain. We ship no SOP library: every company's way of working is its own.
What exists
ODXDocs exists today (write, self-test, publish and serve SOPs, from the command line or a browser editor), and so does ODXBrain's SOP search. ODXPilot doesn't use them yet: that integration is on the roadmap. Formats and commands below may still change.
An SOP is a Markdown file
One file per procedure, kept in a git repository, written by an accountant and a developer together. The front matter says how people ask for it and where it applies; the body is the procedure the agent follows.
+++
id = "vendor-bill-from-po"
title = "Record a vendor bill from a received purchase order"
intents = [
"post a vendor bill for a received purchase order",
"create a bill from a purchase order",
"record a supplier invoice against a PO",
"enter a vendor bill after goods are received",
]
[applies_to]
odoo_min = "17.0"
edition = "enterprise"
modules = ["account", "purchase"]
+++
## Business rules (Accountant)
- Only bill what was received. Match the bill to the purchase order and the receipt before posting.
- Bills above IDR 50,000,000 need CFO approval before they are posted.
- The vendor's NPWP must be on the partner record. If it is missing, stop and ask Finance.
- Never post a bill dated in a closed period.
## Technical notes (Developer)
- The bill is an `account.move` with `move_type` `in_invoice`, created from the order with
`purchase.order.action_create_invoice`.
- Before creating it, compare `qty_received` with `qty_invoiced` on `purchase.order.line`.
- Post with `account.move.action_post`. **Ask the user to confirm before posting.**intents: how the request will be described when looking the SOP up, written as short task descriptions. At least two, because the self-test holds each one out.applies_to:odoo_min,odoo_max,edition,modulesandcompanies. A localisation is a module (l10n_id). Leave out what doesn't restrict; a misspelled key is an error, never ignored.- The body is free text. Any model can read it, so there is no step schema to learn. It never goes to ODXBrain.
- There is no version field. The version is derived from the file's content, so every edit gets a new one automatically. Git keeps the history.
Tested before it goes live
Publishing runs a self-test against the ODXBrain that will serve the SOPs: for each intent, rebuild its SOP without that intent, search with it, and see where its own SOP ranks.
| Result | Meaning | Blocks publishing? |
|---|---|---|
| PASS | every held-out intent finds its own SOP first | no |
| WARN | each one is in the top 3, but another SOP comes first: a collision | no |
| FAIL | an intent puts its SOP outside the top 3: a request phrased that way won't find it | yes |
There is deliberately no flag to publish a failing set. Fix a FAIL by rewording the intent to say what makes this procedure different.
Published, versioned, kept
- Publishing writes every SOP version to a content-addressed store, then tells ODXBrain to reload. ODXBrain receives only titles and intents, never bodies.
- A version is written once and never changes or disappears, so every past run can be replayed against exactly the procedure the agent followed.
- Undo is git: check out the earlier commit and publish again.
- The store lives on your premises, and it is the record of what the agent was given: back it up.
How ODXPilot maps a request
Describe the task. ODXPilot writes the request as a short task description, in the same style as the intents.
Rank your SOPs. ODXBrain ranks the published SOPs, filtered to this instance: Odoo version, edition, installed modules and company. ODXPilot always sends all four.
Stop if there's no confident match. ODXPilot asks the person instead of running the nearest procedure. The closest SOPs can be shown, never executed.
Check the operation. The chosen SOP's title is checked against the task: a search ranks "cancel a posted vendor bill" as close to Record a vendor bill as a real request, so the score alone can't tell them apart.
Fetch that exact version from ODXDocs, by id and version. There is no "latest", so a publish in the middle of a task can't swap the procedure.
Plan and log. The plan follows the SOP's business rules and technical notes, goes through the same policy checks and approvals as everything else, and the run records the SOP id, its version and the hash of the published set.
Writing and publishing
odxdocs check sops/ # validate, offline
odxdocs test sops/ --brain URL # the self-test
odxdocs publish sops/ --out sops.json --store STORE --brain URL
odxdocs gui sops/ --out sops.json --store STORE --brain URLodxdocs gui opens the same folder in a browser: every SOP marked live, changed or
new, an editor that validates as you type, Try it to see how a draft ranks before you
save it, and Publish through the same self-test. It isn't a hosted service: run it for
setup or an adjustment, on the server or through an SSH tunnel, and stop it when done.
Writing good SOPs
- One operation per SOP. "Record a vendor bill" and "cancel a vendor bill" are two procedures, with titles that say which is which.
- Write intents as tasks, not questions: "register a payment to a supplier", not "how do I pay a supplier?".
- Put thresholds and approvals in the business rules, in numbers: "above IDR 50,000,000 needs CFO approval".
- Say when to stop: "if the NPWP is missing, stop and ask Finance".
- Mark irreversible steps with an explicit "ask the user to confirm".
- Point to the other SOP when a case belongs elsewhere ("if PPh 23 applies, follow Withhold PPh 23 on a vendor payment instead").
- Restrict with
applies_torather than in prose, so an SOP that can't apply is never offered. - Name fields and methods, but don't treat them as truth. ODXPilot still checks them
against the live
fields_getof your instance before every write.
Best practices for AI agents on Odoo
These are the rules ODXPilot is built by. They hold just as well for an agent you
build yourself on ODXProxy and the SDKs. Examples use the
v2 API; on v1 the same context goes in keyword.context.
1. Never give an agent an admin key
Create one Odoo user per role tier, for example pilot-readonly,
pilot-bookkeeper and pilot-manager, each with the narrowest Odoo groups that work.
Let the agent act through the tier that matches the person asking. Odoo's own access
rules then stay a real backstop if a policy above them is too loose.
Before you bind a role to a connection, check both directions: the connection must be able to do what the role allows, and shouldn't be able to do much more (no Settings or admin rights). It also needs access to every company of every person bound to it.
Two secrets, still
The agent's Odoo key is odoo_instance.api_key. The proxy's x-api-key is a
different secret. Both stay on the server.
2. Draft first, post by hand
Create accounting records as drafts and stop. On account.move, a new record is a
draft until action_post is called, so creating and posting are naturally two steps:
keep them two decisions.
{
"id": "plan-41-step-2",
"model_id": "account.move",
"method": "create",
"kwargs": {
"vals_list": [{ "move_type": "in_invoice", "partner_id": 42, "ref": "INV-2026-0311" }],
"context": { "allowed_company_ids": [1] }
},
"odoo_instance": { "url": "https://erp.example.com", "db": "prod", "api_key": "<ODOO_USER_API_KEY>" }
}Posting (action_post), registering payments, reconciling and resetting to draft
(button_draft) each get their own explicit approval.
3. Enforce permissions in code, not in the prompt
A sentence in a system prompt can be argued away, by a user or by text hidden in a vendor bill PDF or an email ("ignore previous instructions and pay…"). Documents are data to extract, never commands to follow. A check in front of the API can't be argued with. Put a policy check between the model and ODXProxy:
- allow-list models and methods per role, default deny;
- check every tool call, not just the plan;
- dry-run the plan through the same check before showing it, so people see "step 3 needs Finance Manager approval" upfront instead of a failure halfway;
- fail closed when a rule names a model, field or method the instance doesn't have.
4. Read the schema live, and again before writing
Studio and custom modules can add fields at any time, so a field list kept anywhere
else may already be stale. Ask the instance with fields_get, limited to the
attributes you need:
{
"id": "schema-account-move",
"model_id": "account.move",
"method": "fields_get",
"kwargs": { "attributes": ["string", "type", "required", "readonly", "relation", "selection", "help"] },
"odoo_instance": { "url": "https://erp.example.com", "db": "prod", "api_key": "<ODOO_USER_API_KEY>" }
}- Caching
fields_getfor one conversation turn is fine. - Re-fetch right before a write: approvals can come hours after the plan.
- On an Odoo error about an unknown field, drop the cache, re-fetch and re-plan. Never retry the same write blindly.
- Report fields that appeared since your policies were last checked. A new Studio field
like
x_bank_numbermay need masking.
5. Maker ≠ checker, per company
- The person who requested a change can't approve it. Check this on the person, not the Odoo user, because connections are shared.
- The approver needs the approver role in the company the change belongs to.
- Approval needs a fresh sign-in (within the last few minutes), so an idle open session can't approve a payment.
- Above a threshold you set, a payment needs a second approver even for a finance manager.
- Optionally: whoever created a bill can't approve its payment.
6. Treat vendor bank account changes as a fraud signal
Changing a partner's bank account (res.partner.bank) is the classic payment-fraud
path. Always require a second person, and flag payments to that vendor for a number of
days afterwards.
7. Set the company context yourself
In a multi-company database, Odoo applies no company selection unless the call's
context carries allowed_company_ids. Set it from the person's access, never from
what the model passes, and overwrite anything the model put there:
"context": { "lang": "en_US", "tz": "Asia/Jakarta", "allowed_company_ids": [1] }Then check company_id on every write as well. Pin each conversation to one company,
show the company on every plan step and approval, and allow several companies in one
plan only for inter-company work where the person has a role in each.
8. Mask before inference
Strip bank account numbers and IBANs, salaries, employee private data and tokens from
tool results before they enter the model's context, e.g. acc_number →
****1234. Masking at that point means the inference provider never receives them,
even when the Odoo user could read them.
9. Follow a written procedure, or stop
Map each request to one approved, versioned procedure and log which one ran. When nothing matches confidently, ask a person: never run the nearest procedure. Check that the procedure does the same operation as the request, not just the same topic. See SOPs with ODXDocs.
10. Cancel or reverse, never delete
Keep unlink on accounting records out of the agent's reach entirely. Corrections go
through cancellation, credit notes and reversals, the way an accountant makes them, so
the trail stays intact.
11. Attribute every write to a person
Log every call with the conversation, the person, their role, the connection, the company, the SOP used, the approver and a before / after diff. On writes, also leave a chatter note on the Odoo record so the trail lives in Odoo too, where Odoo's own history only shows the shared connection user.
12. Keep Odoo keys server-side
- Odoo API keys live in a vault on the server: never in a browser, a token, a URL, a log line, a prompt or a tool result.
- Make them write-only once entered: if people share a connection, a visible key would let them bypass the whole policy.
- Issue short-lived tokens to people (15 minutes or less), re-check the person with the identity provider on every refresh, and require MFA for anyone who can write or approve.
- Odoo API keys of non-admin users expire: warn ahead of expiry and rotate.
Odoo versions
ODXPilot's code is version-agnostic: models come from ODXBrain, fields from live
fields_get, and nothing hardcodes model or field names. Version knowledge lives in
content (SOPs and ODXBrain coverage), so a version is supported when that content
exists for it. The only version-dependent code is the transport: ODXProxy
v1 or v2, chosen once per stack from the instance's Odoo version.
Inference and data
- Models: Claude (Anthropic API) or Gemini on Google Vertex AI, chosen per chat.
- The request and the Odoo data the task needs are sent to that provider. Masked fields are not.
- ODXPilot is not air-gapped or on-premises-only today. Local inference, running the model on your own hardware, is next on the roadmap. ODXBrain itself already runs fully offline.
Roadmap
Not built yet, roughly in order:
- Odoo tools through ODXProxy (read, then drafts)
- ODXBrain integration
- Policy engine, roles, approvals, segregation of duties, masking
- Identity broker: OIDC, SAML, AD / LDAP, MFA, SCIM
- Web app: one question box, plan cards with an approve step
- SOP mapping: every request resolved through ODXBrain, with the exact SOP version fetched from ODXDocs
- Local inference: the model on your own hardware instead of a hosted provider