ODXProxy

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 gatekeeper

One 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 modelODXPilot (deterministic)
Understands the requestyes
Picks the procedure (SOP)describes the taskmaps it to one published SOP version filtered to this instance, or stops
Writes the planyesevery model / field must exist in live fields_get
Decides permissionsneverrole policy, default deny
Sees masked fieldsnevermasked before the data reaches the model
Posts, pays, reconcilesnever on its ownonly after approval by the right role

Tool tiers

TierOdoo callsRule
Readsearch_read, read, search_count, fields_getrun freely, within the role
Writecreate, writethe person approves the plan
Irreversibleposting (action_post), payments, reconciliation, unlinkexplicit 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

RoleCan do
auditorread accounting data, bank numbers masked, no writes
billing_clerkdraft 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_managerpost, register payments, approve bank account changes, set lock dates; still bound by segregation of duties
tenant_adminmanages 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: deny

SOPs 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, modules and companies. 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.

ResultMeaningBlocks publishing?
PASSevery held-out intent finds its own SOP firstno
WARNeach one is in the top 3, but another SOP comes first: a collisionno
FAILan intent puts its SOP outside the top 3: a request phrased that way won't find ityes

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 URL

odxdocs 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_to rather 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_get of 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_get for 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_number may 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

On this page