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
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.
Fail closed
Ambiguity resolves toward a structured refusal, never toward a silent success or a guess.
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.
Proof comes from the database
Success is reported only after the committed row is read back and matches the intent.
Evidence survives the outcome
Refusals and failures are recorded on an independent transaction, so they do not vanish with a rollback.
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
| Part | Responsibility |
|---|---|
nanti_mcp_core | MCP server, per-user OAuth identity, scoped API keys, fail-closed tools, base action logging |
nanti_mcp_verify | Verified writes, idempotency, atomic batches, stored Action Receipts |
nanti_mcp_governance | Bundles, tool policies, hidden and required fields, preconditions, export controls |
nanti_mcp_approvals | Risk classes, payload-bound approvals, separate approvers |
nanti_mcp_audit | Before and after snapshots, record history, immutable mode, alerts, dashboard |
nanti_mcp_semantics | Semantic 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-serverand/.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:readandodoo: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.
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.
- 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 - 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 - 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 - 04 Semantics
Meaning is injected, sensitive fields are withheld, and required-for-AI fields are enforced.
FIELD_REQUIRED · FIELD_FORBIDDEN - 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 - 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 - 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 - 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 - 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 Audit
The evidence row, opened before execution on an independent transaction, is finalized; refusals are recorded too.
AUDIT_UNAVAILABLE (strict mode) - 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:
- execute
- flush
- read back and compare
- COMMIT
- read again after commit
- 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.
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.
- PrepareThe exact change is staged server-side; the call returns
APPROVAL_REQUIREDwith a request id. - 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.
- ExecuteThe agent retries the identical call with the request id, and only that approved payload runs.
The approval token binds
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.
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.
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.
- StageThe first send returns unsent as
CONFIRMATION_REQUIRED, with the exact preview and acnf_token: a SHA-256 of the canonical payload, single use, valid 10 minutes. - ConfirmA person reads the words and says yes; the AI retries with the token.
- Send or refuseIf one word changed, or the token expired or was reused, it is
CONFIRMATION_STALEand 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.
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).
Edits and deletes of audit records through Odoo are blocked.
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.
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.
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
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.
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 = 0is 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_versionand atool_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
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.
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.
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.
