Technical whitepaper · Version 1.1 · September 2026

The Execution Layer for Odoo.

Architecture, security model, and verified execution for AI actions in Odoo. For the architects, CTOs, and security reviewers who have to sign off before an AI writes to the ERP.

Audience technical decision makers Reading time about 25 minutes Applies to Odoo 19; Odoo 20 in progress
Start reading

Abstract

An AI client connected to Odoo can do the wrong thing and report success. It can read a truncated list and answer as if it were complete, write to the wrong record, retry a timed-out request and write twice, or leave a multi-step change half applied. None of these raises an error, and none can be caught by reviewing the plan beforehand, because they happen during execution.

The Execution Layer for Odoo is an MCP server installed inside Odoo that routes every AI action through one fail-closed pipeline: a real user’s identity, an intersection of permissions that can only narrow, server-side business rules, payload-bound human approval, idempotent and atomic execution, a read-back from the database after commit, and an immutable audit written even when the action is refused. This paper describes each mechanism, the threats it answers, how it is tested, and how it compares with the native MCP server in Odoo 20.

1

The problem: failures at execution time

The Model Context Protocol solved connection. Any AI client can now reach Odoo through a standard interface, and Odoo 20 ships its own MCP server. Connection is the easy half. The hard half is what happens when the AI acts, because the failures that matter in an ERP do not look like failures.

  • Silent success. The tool reports success, but an onchange, a compute, or a constraint left the record different from what was asked.
  • The truncated read. A bounded search returns the first page, and the model answers as if it saw everything.
  • The wrong target. Two customers share a name and the model picks one.
  • The double write. A request times out, the client retries, and the order exists twice.
  • The half-applied change. Step seven of ten fails after steps one to six committed.

Plan review and prompt instructions cannot catch these, because the plan was right. The execution went wrong. The Execution Layer exists to make execution itself provable.

2

Design principles

01

One pipeline, no fast path

Every call, a read or a destructive write, passes the same stages. No caller is trusted enough to skip one.

02

Fail closed

Ambiguity resolves toward a structured refusal, never toward a silent success or a guess.

03

Never more privileged than the person

The AI acts as a real Odoo user, and every layer of permission can only narrow what Odoo already allows.

04

Proof comes from the database

Success is reported only after the committed row is read back and matches the intent.

05

Evidence survives the outcome

Refusals and failures are recorded on an independent transaction, so they do not vanish with a rollback.

06

Verify persistence and permissions, not judgment

The layer proves what happened and that it was allowed. It does not decide whether the AI’s idea was a good one.

3

Architecture

The Execution Layer is one installable Odoo module, nanti_mcp_enterprise, running inside the customer’s own Odoo. There is no external service in the request path and nothing to pip install: its only third-party Python dependencies, requests and cryptography, are already part of Odoo’s own requirements.

AI clients

Claude · ChatGPT · Cursor · any MCP client

Your Odoo · self-hosted or Odoo.sh

Execution Layer /nanti/mcp

core MCP server, OAuth, keys verify receipts, idempotency, batches governance bundles, rules, fields approvals risk classes, four-eyes audit evidence, history, alerts semantics registry, meaning
Odoo ORM · access rights and record rules · PostgreSQL
Six parts, one installable module
PartResponsibility
nanti_mcp_coreMCP server, per-user OAuth identity, scoped API keys, fail-closed tools, base action logging
nanti_mcp_verifyVerified writes, idempotency, atomic batches, stored Action Receipts
nanti_mcp_governanceBundles, tool policies, hidden and required fields, preconditions, export controls
nanti_mcp_approvalsRisk classes, payload-bound approvals, separate approvers
nanti_mcp_auditBefore and after snapshots, record history, immutable mode, alerts, dashboard
nanti_mcp_semanticsSemantic registry, meaning injection, disambiguation, the Context Layer client

All six are source-available in the customer’s installation: the source you install is the source you can read. The endpoint is POST https://<your-odoo>/nanti/mcp: MCP Streamable HTTP, stateless, with JSON responses, no session id and no streaming. The server speaks MCP protocol revisions 2025-06-18 (preferred), 2025-03-26, and 2024-11-05. Endpoints and OAuth routes are vendor-namespaced under /nanti/ so the layer coexists with Odoo’s own /mcp and with any other MCP module on the same database.

4

Identity and authentication

Every call executes as a real Odoo user. There is no service account and no shared key standing in for a person, so the audit can always say who.

  • OAuth 2.1 authorization serverShipped in the module, because Odoo ships no OAuth provider of its own. Issuer <base>/nanti; dynamic client registration (public clients) at /nanti/oauth/register, then /nanti/oauth/authorize, /nanti/oauth/token, and /nanti/oauth/revoke; discovery at /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource. PKCE S256 only; redirect URIs must be HTTPS or loopback and match exactly.
  • Token lifetimesAuthorization codes: 5 minutes, single use. Access tokens: 60 minutes. Refresh tokens: 30 days, rotated on use, and reusing an old one revokes the whole family. Scopes odoo:read and odoo:write. Registration and token endpoints are rate-limited per source address.
  • Browser consentThe client opens Odoo’s login, the person signs in as themselves and clicks Allow, and picks a connection level (read, write, or full) and optionally a subset of models.
  • TokensTokens and codes are stored as SHA-256 hashes. There are no server sessions, so revocation is immediate.
  • Scoped API keysFor unattended jobs: nmk_… keys, stored hashed, with six independent scopes, an optional expiry, an IP allowlist, a per-key rate, and one-click revocation.
  • Access levelsFour per user: No access, Connect an AI client, Auditor, Administrator. A single check runs on every authentication, so No access refuses that user’s existing tokens and keys at once, and reversibly; system administrators cannot be locked out.
Execution Layer · connections Real product · demo data
Every connection: one AI client, one Odoo user, its permission, last use, and a revoke control.

5

Authorization

Effective permission is an intersection. Every term can only narrow what Odoo already allows.

Odoo access rights and record rules
the user’s bundle
this connection’s level and models
the token’s scopewhat the AI may do
  • BundlesPlain-language roles that decide, per user, which models and fields are in scope, which domains apply, which tools exist, whether export is allowed, and the maximum records per read. The product defines six presets: read only (the default), read and write, full, sales, finance, and support.
  • Absent, not deniedTools outside a bundle do not appear in the tool list at all, so the model never learns they exist; a direct call is still refused with TOOL_NOT_AVAILABLE. Every refusal names the layer that blocked it: Odoo’s access rules, a record rule, the bundle, or the connection.
  • Structurally blocked modelsNever reachable, whatever the bundle: the layer’s own configuration and audit, ir.*, res.users*, res.groups, auth.*, bus.*, and base.*.
  • FieldsHidden fields are absent from describe_model; fields can be read-only for AI where Odoo would allow a write; required-for-AI fields refuse a write that omits them (FIELD_REQUIRED).
  • PreconditionsDeclarative business rules (set, one of, related record exists, field comparison) or an expert Python expression, each rendered as a sentence with a fix location. A failure is PRECONDITION_FAILED, never a silent skip.
  • Business buttonsexecute_action calls only methods on a per-model allow-list, empty by design; generic ORM and private methods are hard-blocked, and a refusal (ACTION_NOT_ALLOWLISTED) suggests the model’s available action_* methods.
  • SensitivityFields are Normal, Internal, Confidential, or Personal data; the last two are hidden from AI unless a bundle allows them.
  • SimulatorRuns the policy-to-approval stages as any user and validates the write inside a savepoint that is always rolled back, returning allow, refuse, or approval and the stage that decided.
Execution Layer · users & permissions Real product · demo data
Execution Layer · a bundle Real product · demo data

6

The request pipeline

Every tool call runs these stages in order. There is no bypass and no configuration that turns a stage off for a trusted caller, so the guarantees hold for the boring writes as much as the dangerous ones.

  1. 01
    Identity

    The bearer token (OAuth or a scoped API key) resolves to a real Odoo user, and the per-minute rate limit is checked. Refused calls still count.

    401 with a discovery pointer · RATE_LIMITED
  2. 02
    Policy

    Bundle, connection level, and tool policy decide whether this tool and model are in scope. The refusal names the layer that blocked it.

    TOOL_NOT_AVAILABLE · MODEL_FORBIDDEN · EXPORT_DISABLED
  3. 03
    Schema

    A fail-closed sanitizer checks the arguments. Unknown, hidden, or out-of-scope fields refuse the whole call; values are never dropped or coerced.

    SCHEMA_VIOLATION · INVALID_ARGUMENT · FIELD_UNKNOWN · FIELD_FORBIDDEN · LIMIT_EXCEEDED
  4. 04
    Semantics

    Meaning is injected, sensitive fields are withheld, and required-for-AI fields are enforced.

    FIELD_REQUIRED · FIELD_FORBIDDEN
  5. 05
    Preconditions

    Declarative business rules are evaluated on the sanitized payload, never on record text, with a sentence that says how to fix them.

    PRECONDITION_FAILED
  6. 06
    Approval

    A risk-classed action is staged and refused until a person approves that exact payload.

    APPROVAL_REQUIRED · APPROVAL_PENDING · APPROVAL_STALE · APPROVAL_DENIED
  7. 07
    Idempotency

    The write’s key is claimed on an independent transaction: the same key and payload replays the original receipt.

    IDEMPOTENCY_CONFLICT · IDEMPOTENCY_KEY_REQUIRED
  8. 08
    Execute

    The change runs in one transaction under a statement timeout equal to the call budget; Odoo’s own exceptions map to stable codes.

    PERMISSION_DENIED · TARGET_NOT_FOUND · CONSTRAINT_VIOLATION · TIMEOUT · COMMIT_FAILED
  9. 09
    Read-back and verify

    In-transaction read and compare, explicit commit, then a fresh transaction re-reads every stored field.

    VERIFICATION_FAILED · ASSERTION_FAILED
  10. 10
    Audit

    The evidence row, opened before execution on an independent transaction, is finalized; refusals are recorded too.

    AUDIT_UNAVAILABLE (strict mode)
  11. 11
    Response

    An Action Receipt for a write, a structured result for a read, or a coded refusal, with a text mirror. INTERNAL carries only an error id.

    receipt · result · refusal

7

Verified execution

The order around the commit is what makes “verified” mean something:

  1. execute
  2. flush
  3. read back and compare
  4. COMMIT
  5. read again after commit
  6. receipt

After the commit, verification opens an independent transaction (read committed, a fresh cursor), re-reads every stored field of the written records, normalises relations to ids, and compares the result with the in-transaction read-back and with the values the client asked for. verified: true is set only on a match. If an onchange or a compute changed a value, the write stands, the receipt says verified: false with the expected and actual values, the audit row is marked verification_failed, and an alert fires. A failure before the commit is COMMIT_FAILED with nothing persisted.

{
  "receipt": {
    "receipt_id": "rcp_01J...",
    "tool": "update_records",
    "model": "project.task",
    "record_ids": [453],
    "executed": true,
    "persisted": true,
    "verified": true,
    "audited": true,
    "changed_fields": { "453": ["user_ids", "stage_id"] },
    "approval": { "request_id": null, "approved_by": null },
    "idempotency": { "key": "...", "replay": false },
    "timing_ms": { "total": 84, "execute": 21, "readback": 6, "audit": 9 }
  }
}

An Action Receipt, representative and abridged. In the app the last step reads Recorded; the JSON field is audited.

Execution Layer · Action Receipt Real product · demo data

Idempotency

Every write can carry an idempotency_key of up to 128 characters, unique per user, tool, and key and claimed with a row lock on an independent transaction. The same key with the same payload returns the stored response with replay: true and the original receipt id, so a retried call runs once; a different payload, or a retry while the original is still running, returns IDEMPOTENCY_CONFLICT. Keys live 24 hours by default (configurable from 1 hour to 30 days) and are required for batches, deletes, business actions, and sends.

Atomic batches

batch groups up to 100 operations. In atomic mode, the default, a failing item k means nothing persists, later items never run, and the call returns BATCH_ITEM_FAILED with index k. An explicit best_effort mode runs each item in its own savepoint and reports status: partial.

The truncation contract

Every bounded read returns count, total_count, truncated, has_more, and next_offset. The default read limit is 50, the maximum 200, and an export can return up to 5,000. A partial list cannot pose as the whole.

8

Approvals

Risk classes gate the actions that should never happen on an AI’s say-so. Five are defined as defaults: deleting anything, mass updates of ten or more records, validating invoices and bills, confirming orders, and outbound mail. Two more, outbound messaging and outbound WhatsApp, catch AI sends for users set to the Approval level. Classes match on tools, models, methods, batch operations, and a minimum record count.

  1. PrepareThe exact change is staged server-side; the call returns APPROVAL_REQUIRED with a request id.
  2. ApproveA person decides in Odoo’s own activity inbox, never through a tool the AI can call. The approver can be someone other than the requester.
  3. ExecuteThe agent retries the identical call with the request id, and only that approved payload runs.

The approval token binds

usertoolmodelrecord idspayload hashpolicy version

The payload hash is a SHA-256 of the canonical JSON of the arguments (sorted keys, compact, without the idempotency, approval, and confirmation fields). The policy version is a hash over the risk class and the bundle as they stood. Any drift after approval returns APPROVAL_STALE with the list of what changed.

Requests (apr_ plus a ULID) are created on an independent transaction, so the refusal cannot roll them back. They expire after 24 hours by default, and an expired request simply raises a fresh APPROVAL_REQUIRED. Decisions are immutable rows made only in Odoo’s interface, and the requester cannot approve their own request unless a class is explicitly set to allow it.

Execution Layer · approval request Real product · demo data

9

Meaning and disambiguation

A versioned semantic registry holds, per model and field, what the field means, its aliases, allowed values, sensitivity, examples, and disambiguation rules, in the Context Layer entry format. Its content is injected into the tool schemas the agent receives and folded into the tool_schema_hash, so a changed meaning is a detectable change.

{
  "error": "AMBIGUOUS_TARGET",
  "message": "'John Smith' matches 3 res.partner records; refusing to guess.",
  "candidates": [
    { "id": 282, "display_name": "John Smith" },
    { "id": 281, "display_name": "John Smith" },
    { "id": 255, "display_name": "John Smith" }
  ],
  "required_action": "clarify_before_execution"
}

A deterministic refusal. A reference that matches nothing returns UNRESOLVED_REFERENCE.

Prompt injection. Returned text and HTML carry provenance (model, id, field, author, and whether it came from outside) and are tagged as data, never as an instruction. Authority comes from the token and the bundle, not from text the model reads.

The Context Layer. Where the Context Layer for Odoo is used, the integration is advisory-only: status is connected, degraded, or none, the pipeline never blocks on it, the local pack cache is hash-verified, and every outbound call is itself audited.

Execution Layer · meaning registry Real product · demo data

10

Governed messaging

Since version 1.1 an AI can send Discuss channel, group, and direct messages to internal users, post notes and comments on record chatter, and send WhatsApp through Odoo’s own Enterprise WhatsApp integration and the customer’s own Meta business account. The layer never talks to Meta itself. There is no email or SMS tool.

  1. StageThe first send returns unsent as CONFIRMATION_REQUIRED, with the exact preview and a cnf_ token: a SHA-256 of the canonical payload, single use, valid 10 minutes.
  2. ConfirmA person reads the words and says yes; the AI retries with the token.
  3. Send or refuseIf one word changed, or the token expired or was reused, it is CONFIRMATION_STALE and nothing sends.

Levels are set per user and per channel: Off, Confirm each message (the default), Approval in Odoo, and Unrestricted. WhatsApp is off by default and template-first; free-form replies work only inside an open 24-hour window behind their own switch; bulk sends above five recipients always need approval, at every level. Hourly limits default to 30 Discuss and 10 WhatsApp sends per user. WhatsApp sends are queued through Odoo’s own WhatsApp queue, never sent inside the transaction, and delivery is reported only when Meta confirms it. Every internal message carries a visible mark, 🤖 AI message · client · sent as user by Nanti AI EL. The messaging tools never read message contents, and the generic write tools refuse the message models entirely.

Execution Layer · messages log Real product · demo data

11

Evidence and audit

Every call is recorded, whether it succeeds, is refused, or fails. Writes record before and after snapshots of every touched field and the list of changed fields. The evidence row is written on a cursor independent of the business transaction, so a refusal survives a rollback. In strict mode a failed audit write fails the call (AUDIT_UNAVAILABLE).

1ORM guard

Edits and deletes of audit records through Odoo are blocked.

2PostgreSQL trigger

A trigger before update or delete on the audit, touch, and receipt tables refuses every delete and every update except the one move from in progress to final; only the retention job may bypass it.

3Hash chain

Each row’s hash is a SHA-256 over the previous row’s hash and the row’s canonical content; a daily job verifies the chain and raises a chain-break alert.

Disabling the audit is itself an audited security action. Retention defaults to 90 days and purges only a contiguous prefix, leaving an anchor row so the chain still verifies. Six default alert rules route what matters to an Odoo activity, email, or a webhook signed with HMAC-SHA256, and each alert records its delivery state, attempts, and last error. When AI edits a business record, a marked note lands in that record’s chatter, so the people who work the record see it too.

Execution Layer · record history Real product · demo data
Execution Layer · action detail Real product · demo data

12

The error model

There are no prose-only errors. Every refusal is one of 42 stable codes in four families, each with a retryable flag, a plain sentence, the menu path where it can be fixed, and the audit id. Existing codes never change meaning; additions are minor versions. Three are honest-absence codes (AMBIGUOUS_TARGET, UNRESOLVED_REFERENCE, FIELD_UNKNOWN): the layer would rather say it does not know than guess.

Request 6

the call itself is malformed

Policy 15

the action is not allowed, or needs a person

State 14

the world did not match the request

Server 7

something underneath did not hold

Execution Layer · a refused call Real product · demo data

13

Threat model

Each attack path, the control that answers it, and the adversarial cases that exercise it. Each case asserts three things: the response, the database state, and the audit row.

Attack pathControlCases
Flooding and oversized requests 1 MiB body cap, per-identity and per-address limits, a 60-second budget, no streaming sessions. AS-40
Token theft, PKCE downgrade, open redirect, code interception, key guessing PKCE S256 only, single-use 5-minute codes, tokens stored as SHA-256 hashes, refresh rotation with reuse detection, exact redirect match, prefixed hashed keys with an IP allowlist. AS-30 to AS-35
Laundering actions through a service account Every token is a named Odoo user. AS-36
Reaching tools or models beyond the bundle Absent tools, an empty method allow-list, and a structural blocklist of control models. AS-20 to AS-24
Field smuggling and domain injection Fail-closed sanitizer, validated domains, no dynamic Python or raw SQL from the client. AS-01 to AS-05
The wrong record targeted Deterministic ambiguity refusal with candidates. AS-10 to AS-12
Instructions hidden in record text Text has no authority; it returns with provenance tagged as data; rules evaluate the payload, not text. AS-13, AS-14
Bypassing business rules Rules run on the effective payload; a rule that cannot be resolved refuses at configuration time. AS-25
Approving one payload and executing another, self-approval, replay SHA-256 payload binding, decisions only in Odoo’s interface, expiry, single use, no self-approval unless a class explicitly allows it. AS-26 to AS-29
Double execution Idempotency keys claimed with row locks on an independent transaction. AS-06, AS-07, AS-45
A partial batch or a re-executed controller Atomic batches by default and an explicit commit inside the pipeline. AS-08, AS-09, AS-41
A false “verified” A fresh read on an independent transaction after commit. AS-42
Evidence lost or edited, through sudo(), SQL, or an administrator Independent transaction, ORM guard, PostgreSQL trigger, SHA-256 hash chain, audited disable, strict mode. AS-15 to AS-19
Leaking data through error messages Redacted actual values; internal errors return only an error id. AS-43
Exfiltration through the Context Layer Identifiers-only egress, signed bundles, and an off switch. AS-55 to AS-59
Bulk export Per-bundle disable and audited row counts. AS-44
Races across workers All shared state in database rows with locks; the suite runs on two or more workers. AS-45
Messaging impersonation and spam Per-user levels, payload-bound previews, member-only targets, the AI mark, hourly limits, WhatsApp off by default and queued. AS-85 to AS-100

14

Assurance

The Execution Layer is gated on 70+ adversarial failure scenarios, green on Odoo 19 Community and Enterprise on every release. The suite ships with the product, so customers run it against their own staging database and read their own report: passed, failed, and not run with the reason. A case that cannot run on a stack is never counted as a pass, and the runner refuses to run against a database that does not look like staging. A reconciler fails our own build if the shipped catalogue and the release gate ever diverge. The shipped runner is one command, python -m nanti_suite run --url https://staging.example.com/nanti/mcp, and reports pass, fail, or not run per case. Two fault-injection cases (AS-41, AS-42) need our CI and report not run on a customer staging.

  • Verified executionAS-01 to AS-14, AS-37, AS-38
  • EvidenceAS-15 to AS-19
  • GovernanceAS-20 to AS-29
  • Identity and edgesAS-30 to AS-36, AS-40 to AS-45
  • Meaning and the Context LayerAS-50 to AS-60
  • Record history and subscriptionAS-81 to AS-84
  • MessagingAS-85 to AS-100
  • Blocked modelsAS-101 to AS-103

There is no third-party penetration test of the Execution Layer, and we do not claim one, or any certification. What we offer instead is verifiable rather than attested: the enterprise modules are source-available in the customer’s installation, the threat model above maps every attack path to a control and a test, and the suite is in the customer’s hands. Vulnerability reports go to hello@nanti.ai.

15

Deployment and operations

  • SupportedOdoo 19, Community and Enterprise, self-hosted or Odoo.sh; one database per host (dbfilter or monodb). Not Odoo Online, which does not allow custom server modules.
  • Scale-outA stateless transport: rate windows, idempotency claims, approvals, and the chain head are database rows with locks, so it behaves the same at one worker or many. Production runs with workers; workers = 0 is for development only.
  • ReachabilityLocal clients (Claude Code, Claude Desktop, Cursor) work over a LAN or VPN. Cloud clients (ChatGPT, the Claude web app) need the Odoo reachable over public HTTPS, terminated at a reverse proxy with proxy_mode on, or on Odoo.sh.
  • Drift detectionEvery toolset carries a semantic toolset_version and a tool_schema_hash, reported at the handshake, so a changed Odoo or toolset is caught before production.
  • SetupOne module folder on the addons path, and a built-in setup wizard.

Limits

Read page50 default, 200 maximum
Export5,000 rows
Record ids per call200
Domain terms50
Batch100 operations
Request body1 MiB
Call budget60 s
Approval expiry24 h default
Idempotency window24 h default, 1 h to 30 days
Rate, per identity120 calls and 20 writes per minute, by default

Measured performance

Measured by nanti.ai on a reference stack: Odoo 19 Community, two workers, Docker on an Apple M5 laptop. Your hardware will differ; the shape should not.

5 msMedian server time, read
17 msMedian server time, write
1 to 3 msAdded by policy to idempotency stages
7,600 calls in 60 sThroughput, 5 concurrent agents
61 ms / 67 msp95, read / write
0Transport errors in that run
about 2.8 KBEvidence per call
25 to 80 µs per rowHash-chain verification
Execution Layer · actions log Real product · demo data
Every call with its status, code, and duration, filterable by refusals, failures, and writes.

16

Data boundaries

Stays on your server

Records, audit, receipts, approvals, and internal messages. The installed modules never phone home and send no telemetry; alerts go only to destinations you choose.

Leaves only if you enable it

Context Layer: the Odoo version and module list, model and field names, and the business term, never values or record ids; a 1.5-second timeout, a sealed token, signed cache bundles, and every call audited.

WhatsApp: message content and the recipient number go to Meta through Odoo’s own integration and your own Meta account, as when a person sends one. Off by default.

17

Compared with Odoo 20’s native MCP

Odoo 20 documents a native MCP server in its AI app. It is a sound way to let an AI read and navigate Odoo, and it can do things the Execution Layer does not: open Odoo’s own views, edit website content, and generate images. The difference is in what happens around a write. Native facts below are from Odoo’s 20.0 documentation; “not described” means that documentation does not cover it.

MechanismOdoo 20 native MCPExecution Layer
Endpoint and transport <db>/mcp through the npx mcp-remote bridge /nanti/mcp, MCP Streamable HTTP, stateless
Authentication Static API key with the MCP scope and a validity period OAuth 2.1 (PKCE S256, dynamic registration, discovery, rotation, revocation); scoped keys for jobs
Tool model Server actions an administrator marks Available in MCP; 27 documented, 5 on by default 20 fixed, schema-hashed tools; availability decided per person by bundle, level, and switches
Read-only A Readonly flag that advises the client Enforced server-side: tools outside the bundle are absent
Before the write Not described Schema sanitizer, preconditions, required-for-AI fields, allow-listed methods, payload-bound approval
After the write Not described Post-commit read-back, Action Receipt, idempotency, atomic batches
Evidence Chatter tracking on tracked fields, as for any user Immutable audit of every call and refusal, before and after values, alerts
Where it runs Odoo’s AI app; not in the Community source Self-hosted and Odoo.sh, Community and Enterprise; not Odoo Online

The two coexist on one database, at /mcp and /nanti/mcp. The use-case comparison, with a decision helper, is at Odoo 20 native MCP vs the Execution Layer, and a vendor-neutral twelve-check scorecard at best MCP server for Odoo 20.

18

Limits and non-goals

  • We verify persistence and permissions. We never claim to verify the AI’s judgment: a well-formed, approved, verified change can still be the wrong business decision.
  • It does not detect prompt injection; it makes record text powerless instead.
  • It does not prevent actions that are permitted but unwise.
  • Against a hostile database superuser it offers tamper evidence, not prevention.
  • It does not run on Odoo Online.
  • It does not navigate Odoo’s screens, edit websites, or generate images.
  • It has no email or SMS tool; outbound messaging is Discuss, chatter, and WhatsApp.
  • The supported deployment is one database per host.
  • It is not certified against any framework, and there is no third-party penetration test.

19

Availability and licensing

Available now for Odoo 19, Community and Enterprise, self-hosted and Odoo.sh. Odoo 20 support is in progress: we have run our full failure suite against Odoo 20.0 Community and are porting the Odoo 20 API changes, for delivery as the first maintenance release, no later than eight weeks after Odoo 20’s general availability.

One module, USD 499 one-time per Odoo major version, everything included: updates within the major and standard support. Existing customers move to the next major at half price. It is sold direct from nanti.ai and on the Odoo Apps Store. The one optional add-on is the Context Layer for Odoo, a lapse-safe annual subscription. Product terms: Execution Layer terms.

A

Appendix: tools and error codes

20 tools

13 read

whoamilist_modelsdescribe_modelsearch_recordsread_recordscount_recordsresolve_recordsexport_recordsget_approval_statusget_record_historylist_conversationslist_whatsapp_templatesget_whatsapp_status

7 write

create_recordupdate_recordsdelete_recordsexecute_actionbatchsend_messagesend_whatsapp

Unavailable tools are absent from the list, not present-but-denied.

42 error codes

Request

INVALID_ARGUMENTSCHEMA_VIOLATIONMODEL_NOT_FOUNDFIELD_UNKNOWNLIMIT_EXCEEDEDTEMPLATE_VARIABLES_INVALID

Policy

MODEL_FORBIDDENFIELD_FORBIDDENFIELD_REQUIREDPERMISSION_DENIEDTOOL_NOT_AVAILABLEACTION_NOT_ALLOWLISTEDPRECONDITION_FAILEDAPPROVAL_REQUIREDAPPROVAL_DENIEDIDEMPOTENCY_KEY_REQUIREDEXPORT_DISABLEDMESSAGING_DISABLEDCONFIRMATION_REQUIREDNOT_A_MEMBERMENTION_NOT_ALLOWED

State

TARGET_NOT_FOUNDCONSTRAINT_VIOLATIONASSERTION_FAILEDAMBIGUOUS_TARGETUNRESOLVED_REFERENCEAPPROVAL_PENDINGAPPROVAL_STALEIDEMPOTENCY_CONFLICTPARTIAL_RESULT_REFUSEDBATCH_ITEM_FAILEDCONFIRMATION_STALEWHATSAPP_NOT_AVAILABLEWHATSAPP_WINDOW_CLOSEDTEMPLATE_NOT_APPROVED

Server

RATE_LIMITEDTIMEOUTCOMMIT_FAILEDVERIFICATION_FAILEDAUDIT_UNAVAILABLECONTEXT_DEGRADEDINTERNAL

Full references: how it works, error codes, security, and the screen-by-screen walkthrough.

The Execution Layer for Odoo · technical whitepaper · Version 1.1 · September 2026 · nanti.ai. Corrections and questions: hello@nanti.ai.

Next

See it refuse, roll back, and receipt.

Install it on a staging copy, run the failure suite, and read your own report. Or start with the plain-language comparison.