Odoo + Cursor AI: Vibe Code Your ERP
Cursor's agent mode was built for large, structured codebases where context is everything. Odoo happens to be exactly that. This guide gives you a complete .cursorrules file, real model and view code, a debugging dialogue, and a deploy path that ships your module on a single git push.
If you're newer to vibe coding Odoo in general, start with the parent guide and come back when you're ready to go deeper with Cursor specifically.
Why Cursor + Odoo Is a Perfect Match
Three things make Cursor particularly effective for Odoo work.
Predictable structure
An Odoo module is a directory with a __manifest__.py, a models/ folder, a views/ folder, a security/ folder, and an __init__.py chain. That consistency means Cursor's agent can navigate confidently. It knows where things live without you explaining the layout every session.
Long context windows
Odoo's ORM is large and cross-references many base models (mail.thread, res.partner, account.move). Cursor's extended context lets you paste in your base model and its parent class, then ask for an extension without losing track of field names or method signatures mid-completion.
Agent mode for scaffolding
The boilerplate in Odoo is real: manifest files, _inherit chains, ir.rule definitions, security CSV rows, XML id attributes. Cursor's agent handles all of it without you typing a single fields.Char. You stay in the design layer.
The key constraint: Cursor doesn't know Odoo out of the box. A .cursorrules file fixes that.
Setting Up Your Odoo Dev Environment for Cursor
Before .cursorrules can help, Cursor needs to see your codebase.
Option 1: Local Odoo source
# Clone the Odoo community source (CE)
git clone --depth=1 --branch=18.0 https://github.com/odoo/odoo.git ~/odoo-src
# Create a project workspace
mkdir ~/my-odoo-project
cd ~/my-odoo-project
# Create a virtualenv
python3.12 -m venv .venv
source .venv/bin/activate
# Install Odoo dependencies
pip install -r ~/odoo-src/requirements.txt
# Install additional dev tools
pip install debugpy pylint-odoo pre-commitOpen Cursor with the workspace pointing to both directories so the agent can cross-reference base Odoo code when you work on your custom modules:
{
"folders": [
{ "path": "~/my-odoo-project/custom-addons" },
{ "path": "~/odoo-src/odoo/addons" },
{ "path": "~/odoo-src/addons" }
]
}Including the Odoo source in your workspace matters. When you ask Cursor to write a method that overrides mail.thread, it can read the actual mail.thread source instead of guessing the API.
Option 2: OEC.sh dev environment
If you'd rather skip local setup, OEC.sh provisions a cloud Odoo instance with SSH access and a git-connected addons path. You can open the remote folder in Cursor via SSH (Cursor's remote development works the same as VS Code's). Your .cursorrules file lives in the remote project root and the agent has full context.
Python extension setup
Install pylsp in your virtualenv so Cursor's language server can follow Odoo imports:
pip install python-lsp-server pylsp-mypyAdd this to .cursor/settings.json in your project:
{
"python.defaultInterpreterPath": ".venv/bin/python",
"python.analysis.extraPaths": [
"~/odoo-src",
"./custom-addons"
]
}requirements.txt for a typical Odoo dev setup
# Core
odoo>=18.0
psycopg2-binary>=2.9
lxml>=5.0
# Dev tools
debugpy>=1.8
pylint-odoo>=9.1
python-lsp-server>=1.11
pylsp-mypy>=0.6
pre-commit>=3.7
# Testing
pytest-odoo>=0.5
coverage>=7.4Writing .cursorrules for Odoo
This is where the setup pays off. A good .cursorrules file makes every Cursor completion Odoo-aware without you repeating yourself. The file below is battle-tested for Odoo 17/18. Copy it to the root of your custom addons directory.
# .cursorrules - Odoo Development Rules
# Version: 2026-04 | Targets: Odoo 17, 18, 19
## Project context
You are an expert Odoo developer. This project contains Odoo custom modules
(addons). Always follow Odoo's framework conventions. Never work around them.
## Python / ORM rules
- Use Odoo ORM methods exclusively. Never write raw SQL unless the task is
explicitly a SQL report or performance-critical batch operation with a comment
explaining why ORM is insufficient.
- Use `self.env['model.name']` to access other models, not direct class imports.
- Use `self.env.ref('module.xml_id')` to resolve external IDs.
- Computed fields must declare `compute='_compute_field_name'` and, if stored,
`store=True`. Always define `depends` via `@api.depends`.
- Inverse fields must declare `inverse='_inverse_field_name'`.
- Constrain methods use `@api.constrains` and raise `ValidationError`, never
`AssertionError`.
- Onchange methods use `@api.onchange` and return warning dicts when needed.
- Never use `cr.execute` or `self._cr.execute` unless inside a method clearly
marked with `# SQL: required for performance`.
- Use `sudo()` sparingly. If you use it, add a comment explaining the privilege
escalation: `# sudo: bypass record rules for background scheduler`.
- `fields.Date.today()` and `fields.Datetime.now()`. Never use Python's `datetime`
directly in field defaults unless converting to Odoo format.
## Model conventions
- Class name: PascalCase matching the model name (e.g. `ProjectResourceBooking`
for `project.resource.booking`).
- `_name`: dotted lowercase (e.g. `project.resource.booking`).
- `_description`: human-readable string, always present.
- `_inherit` for extension, `_inherits` only for delegation inheritance (rare).
- Field order: `_name`, `_description`, `_inherit`, `_order`, then fields
grouped by type (Char/Int/Float > Date/Datetime > Many2one > One2many >
Many2many > computed), then methods ordered: CRUD overrides, compute,
inverse, constrains, onchange, action methods.
- `name` field should be the primary display field; include `_rec_name = 'name'`
if the display field has a different name.
## Field naming
- Boolean fields: prefix with `is_` or `has_` (e.g. `is_active`, `has_attachment`).
- Many2one fields: end with `_id` (e.g. `partner_id`, `project_id`).
- One2many fields: end with `_ids` (e.g. `line_ids`, `booking_ids`).
- Many2many fields: end with `_ids` (e.g. `tag_ids`, `user_ids`).
- Date fields: end with `_date` (e.g. `start_date`, `end_date`).
- Computed fields: name describes the output (e.g. `total_amount`, `duration_hours`).
## Manifest format
- `__manifest__.py` keys in this order: `name`, `version`, `category`,
`summary`, `description`, `author`, `website`, `license`, `depends`,
`data`, `demo`, `assets`, `installable`, `auto_install`, `application`.
- `version`: always `"18.0.1.0.0"` format (Odoo major + module semver).
- `license`: default to `"LGPL-3"` unless told otherwise.
- `depends`: list base dependencies, never over-include. `base` is implicit in
most cases; only add if you actually use models from it directly.
## XML / Views
- XML `id` attributes: `module_name.view_model_name_type`
(e.g. `my_module.view_project_resource_booking_form`).
- Always wrap views in `<odoo><data>` tags.
- Use `<field name="arch" type="xml">` inside `ir.ui.view` records.
- Form views: `<form string="...">` at root, group fields logically with
`<group>` and `<group col="2">`.
- Tree/list views: include only the key fields needed at-a-glance (5-8 max).
- Kanban views: use `<templates><t t-name="kanban-card">` structure.
- Never hardcode record IDs in XML. Always use `ref()` or `%(xml_id)s`.
- Menu items: follow parent > category > action chain.
## Security
- Every model needs a record in `security/ir.model.access.csv`.
- CSV columns in order: `id,name,model_id:id,group_id:id,perm_read,perm_write,
perm_create,perm_unlink`.
- `model_id:id` format: `model_project_resource_booking` (replace dots with
underscores, prefix with `model_`).
- For public read access, use `base.group_user` as the group.
- For admin-only write, use `base.group_system`.
- Use `ir.rule` records for row-level security (multicompany, owner-only).
## Error handling
- Raise `odoo.exceptions.UserError` for user-facing errors.
- Raise `odoo.exceptions.ValidationError` from `@api.constrains`.
- Raise `odoo.exceptions.AccessError` when manually checking access rights.
- Never let exceptions bubble uncaught from public methods.
## Testing
- Test files live in `tests/test_*.py`.
- Test classes inherit `TransactionCase` (or `SavepointCase` for Odoo <16).
- Use `self.env['model'].create({...})` to set up test data, not fixtures.
- Assert using `assertEqual`, `assertTrue`, `assertRaises`. Not raw `assert`.This covers the main failure modes when Cursor generates Odoo code without guidance: raw SQL where ORM should be used, wrong field naming, missing _description, malformed XML IDs, and security CSV with wrong column order. Save it, open Cursor, and from this point forward every completion in this workspace follows Odoo conventions.
Building an Odoo Model with Cursor
Here's a concrete example: a project.resource.booking model that tracks when a shared resource (meeting room, test device, vehicle) is booked against a project task.
The prompt
After adding your .cursorrules, open a new file models/project_resource_booking.py and give Cursor this prompt in agent mode:
Create an Odoo model called project.resource.booking that:
- Has a name field (Char, required)
- Belongs to a project.project via project_id (Many2one)
- Belongs to a project.task via task_id (Many2one, optional)
- Has a resource_id pointing to resource.resource (Many2one, required)
- Has start_datetime and end_datetime (Datetime, required)
- Has a computed duration_hours field (Float, stored) calculated from the two datetimes
- Has an is_confirmed boolean, default False
- Inherits mail.thread and mail.activity.mixin for chatter
- Include @api.constrains to validate end > start
- Include _sql_constraints for no double-booking the same resource in overlapping timesWhat Cursor generates (reviewed and corrected)
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class ProjectResourceBooking(models.Model):
_name = "project.resource.booking"
_description = "Project Resource Booking"
_inherit = ["mail.thread", "mail.activity.mixin"]
_order = "start_datetime desc"
_rec_name = "name"
name = fields.Char(
string="Booking Reference",
required=True,
tracking=True,
)
project_id = fields.Many2one(
comodel_name="project.project",
string="Project",
required=True,
ondelete="cascade",
index=True,
tracking=True,
)
task_id = fields.Many2one(
comodel_name="project.task",
string="Task",
domain="[('project_id', '=', project_id)]",
ondelete="set null",
index=True,
)
resource_id = fields.Many2one(
comodel_name="resource.resource",
string="Resource",
required=True,
ondelete="restrict",
index=True,
tracking=True,
)
start_datetime = fields.Datetime(
string="Start",
required=True,
tracking=True,
)
end_datetime = fields.Datetime(
string="End",
required=True,
tracking=True,
)
duration_hours = fields.Float(
string="Duration (h)",
compute="_compute_duration_hours",
store=True,
digits=(6, 2),
)
is_confirmed = fields.Boolean(
string="Confirmed",
default=False,
tracking=True,
)
_sql_constraints = [
(
"no_overlap",
"""EXCLUDE USING gist (
resource_id WITH =,
tstzrange(start_datetime, end_datetime, '[)') WITH &&
) WHERE (is_confirmed = true)""",
"A confirmed booking already exists for this resource in that time slot.",
),
]
@api.depends("start_datetime", "end_datetime")
def _compute_duration_hours(self):
for rec in self:
if rec.start_datetime and rec.end_datetime:
delta = rec.end_datetime - rec.start_datetime
rec.duration_hours = delta.total_seconds() / 3600.0
else:
rec.duration_hours = 0.0
@api.constrains("start_datetime", "end_datetime")
def _check_dates(self):
for rec in self:
if (
rec.start_datetime
and rec.end_datetime
and rec.end_datetime <= rec.start_datetime
):
raise ValidationError(
"End datetime must be after start datetime."
)What to review manually
The _sql_constraints gist exclusion requires the btree_gist PostgreSQL extension. Check whether your database has it:
SELECT * FROM pg_extension WHERE extname = 'btree_gist';If not, either enable it (CREATE EXTENSION btree_gist;) or replace the constraint with a simpler unique constraint and enforce overlap logic in @api.constrains. Cursor won't know your Postgres setup, so you need to make this call. Everything else in the generated code is correct and follows the .cursorrules conventions.
Writing Views in Cursor
Always give Cursor the model code first. Paste the model into the chat context, then prompt for views.
Prompt pattern
Given the project.resource.booking model above, generate:
1. A form view with two groups: booking details (name, project_id, task_id,
resource_id) and timing (start_datetime, end_datetime, duration_hours,
is_confirmed)
2. A list view showing: name, resource_id, project_id, start_datetime,
end_datetime, duration_hours, is_confirmed
3. A Kanban view grouped by resource_id, showing name, project_id,
start_datetime, end_datetime
Module name: project_resource_bookingGenerated views (production-ready)
<odoo>
<data>
<!-- Form View -->
<record id="view_project_resource_booking_form" model="ir.ui.view">
<field name="name">project.resource.booking.form</field>
<field name="model">project.resource.booking</field>
<field name="arch" type="xml">
<form string="Resource Booking">
<header>
<button
name="action_confirm"
string="Confirm"
type="object"
class="btn-primary"
attrs="{'invisible': [('is_confirmed', '=', True)]}"
/>
<field name="is_confirmed" widget="statusbar"
statusbar_visible="0,1"/>
</header>
<sheet>
<group string="Booking Details">
<group>
<field name="name"/>
<field name="project_id"/>
<field name="task_id"/>
</group>
<group>
<field name="resource_id"/>
</group>
</group>
<group string="Timing">
<group>
<field name="start_datetime"/>
<field name="end_datetime"/>
</group>
<group>
<field name="duration_hours" readonly="1"/>
</group>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- List View -->
<record id="view_project_resource_booking_list" model="ir.ui.view">
<field name="name">project.resource.booking.list</field>
<field name="model">project.resource.booking</field>
<field name="arch" type="xml">
<list string="Resource Bookings"
decoration-success="is_confirmed == True">
<field name="name"/>
<field name="resource_id"/>
<field name="project_id"/>
<field name="start_datetime"/>
<field name="end_datetime"/>
<field name="duration_hours" string="Hours"/>
<field name="is_confirmed" widget="toggle_button"
options="{'terminology': 'active'}"/>
</list>
</field>
</record>
<!-- Kanban View -->
<record id="view_project_resource_booking_kanban" model="ir.ui.view">
<field name="name">project.resource.booking.kanban</field>
<field name="model">project.resource.booking</field>
<field name="arch" type="xml">
<kanban default_group_by="resource_id">
<field name="name"/>
<field name="project_id"/>
<field name="start_datetime"/>
<field name="end_datetime"/>
<field name="is_confirmed"/>
<templates>
<t t-name="kanban-card">
<div class="oe_kanban_card oe_kanban_global_click">
<div class="oe_kanban_content">
<strong>
<field name="name"/>
</strong>
<div class="o_kanban_record_subtitle">
<field name="project_id"/>
</div>
<div class="text-muted small">
<field name="start_datetime"
widget="date"/>
to
<field name="end_datetime"
widget="date"/>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- Action -->
<record id="action_project_resource_booking"
model="ir.actions.act_window">
<field name="name">Resource Bookings</field>
<field name="res_model">project.resource.booking</field>
<field name="view_mode">list,form,kanban</field>
<field name="context">{}</field>
</record>
<!-- Menu -->
<menuitem
id="menu_project_resource_booking"
name="Resource Bookings"
parent="project.menu_main_pm"
action="action_project_resource_booking"
sequence="50"
/>
</data>
</odoo>One pattern worth enforcing: always tell Cursor the module name before asking for views. Without it, the generated id attributes may omit the module prefix, which will cause installation failures if another module ships a record with the same bare ID.
Debugging Odoo with Cursor Chat
Cursor Chat earns its keep in debugging sessions. Here are three common Odoo error types and how to work through them with the agent.
Scenario 1: Constraint error on install
You run odoo-bin -d mydb -i project_resource_booking and get:
psycopg2.errors.UndefinedFunction: function gist_tstz_consistent(internal,
timestamp with time zone, smallint, oid, internal) does not existCursor Chat prompt:
I'm getting this psycopg2 error when installing my Odoo module.
The _sql_constraints uses a GIST exclusion with tstzrange.
Error: gist_tstz_consistent function does not exist.
How do I fix the constraint or enable the right extension?Cursor will correctly identify that btree_gist is missing and offer two paths: enable the extension, or rewrite the constraint as an @api.constrains method. It will also show you the SQL to check whether the extension exists in your database.
Scenario 2: Computed field not updating
You notice duration_hours shows 0.0 even after saving valid datetimes.
My computed field duration_hours (store=True) is always 0.0 after save.
The field depends on start_datetime and end_datetime.
Here is the compute method: [paste _compute_duration_hours].
What could cause the stored value to not update?Typical root cause Cursor will surface: the field was defined before you added store=True, and an older migration left the column unpopulated. The fix is to trigger recomputation:
./odoo-bin shell -d mydb
>>> env['project.resource.booking'].search([])._compute_duration_hours()
>>> env.cr.commit()Scenario 3: Access rights error
A non-admin user gets “You do not have access to Project Resource Booking” despite having the record in ir.model.access.csv.
This CSV gives read/write/create/unlink access to base.group_user but users
still get an access error. Here is the CSV: [paste]. What is wrong?Cursor will check column order and often spot the most common issue: the model_id:id value has dots instead of underscores (project.resource.booking instead of project_resource_booking). That causes the CSV row to silently fail on import, leaving the model with zero access rules.
Vibe Coding a Complete Module End-to-End
Here's the full loop from idea to installable module, as a short walkthrough.
1. Start with .cursorrules in place
Open Cursor in your custom-addons/ directory with the .cursorrules file at root.
2. Scaffold the module
In agent mode:
Create an Odoo 18 module scaffold for project_resource_booking.
Include __manifest__.py, __init__.py, models/__init__.py, views/ and
security/ directories. The module depends on project and resource.Cursor creates the full directory tree in one pass. Review __manifest__.py for correct depends and version string.
3. Generate the model
Use the prompt from the “Building a Model” section above. Review for the PostgreSQL extension constraint, then save.
4. Generate views
Use the prompt from the “Writing Views” section, giving Cursor the model code as context. Check XML IDs include the module prefix.
5. Generate security CSV
Prompt:
Generate ir.model.access.csv for project.resource.booking giving
base.group_user full CRUD access.Output:
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_project_resource_booking_user,project.resource.booking user,model_project_resource_booking,base.group_user,1,1,1,16. Wire up __init__.py and manifest
Cursor handles this if you ask: Update __manifest__.py data list to include all XML files in views/ and the security CSV.
7. Install and test
Restart Odoo with -u project_resource_booking and work through any errors using Cursor Chat as described in the debugging section above.
The complete scaffold-to-install cycle for a new model typically takes 20 to 30 minutes versus several hours writing by hand. The time is spent reviewing Cursor's output, not producing it.
Deploying Your Cursor-Built Module on OEC.sh
Once your module works locally, getting it live is a git operation.
Git push workflow
Commit your module to a repository OEC.sh can reach:
cd ~/my-odoo-project/custom-addons/project_resource_booking
git init
git add .
git commit -m "feat: add project.resource.booking module"
# Push to your repo (GitHub, GitLab, Gitea, etc.)
git remote add origin git@github.com:yourorg/project-resource-booking.git
git push -u origin mainOEC.sh deploy flow
OEC.sh mounts a connected git repository as an addons path. When you push to the configured branch, the platform pulls the update automatically. No SSH required after initial setup.
git push origin main
|
OEC.sh detects push via webhook
|
Pulls updated addons path
|
Runs: odoo-bin -d $DB -u project_resource_booking --stop-after-init
|
Module updated, server continues runningTo install the module for the first time via the OEC.sh dashboard, navigate to Apps, search for your module technical name, and click Install. Or use the module update button after the first git-push install.
For a pipeline that runs your Odoo tests before deploy, see the GitLab CI/CD guide. For pricing and plan details that include the git deploy feature, see pricing.
If you want a cloud environment that deploys on git push without managing Nginx, certificates, or Postgres upgrades yourself, OEC.sh is set up for exactly that. The free tier gets you a running Odoo 18 instance in under 5 minutes. The paid plans add custom addons paths, SSH access for Cursor remote development, and per-project database isolation.
For self-hosted setups, the Deploy Odoo guide covers the full stack from a bare Ubuntu VPS.
Ship modules faster from Cursor
Git push your Cursor-built Odoo modules and OEC.sh deploys them instantly.
- Git-connected addons path
- SSH access for Cursor remote dev
- Free tier, no credit card
Frequently Asked Questions
Does Cursor work with Odoo 18 and 19?
Yes. The .cursorrules file in this guide targets Odoo 17/18 and the conventions are forward-compatible with 19. The main difference in Odoo 19 is the continued migration from attrs (deprecated in 16) to inline Python conditions in view XML. Update your .cursorrules to say "never use attrs=" and replace with invisible= and required= directly on the field tag. Cursor will follow that instruction immediately.
What's the difference between using Cursor vs GitHub Copilot for Odoo?
Copilot completes the line you're on. Cursor's agent mode writes entire files, refactors across multiple files, and follows instructions in .cursorrules for the lifetime of your project. For Odoo specifically, the .cursorrules context is the deciding factor. Copilot has no equivalent mechanism for enforcing Odoo conventions project-wide. If you're already in VS Code and use Copilot, it will help with syntax. If you're choosing an AI IDE for an Odoo project, Cursor's agent plus custom rules is meaningfully more capable.
Can I use Cursor with Odoo Enterprise?
Yes. The ORM, XML view format, and module structure are identical between Community and Enterprise. Enterprise modules add their own models and views, but they're standard Odoo code. Add the Enterprise source directory to your Cursor workspace the same way you'd add Community source, and the agent can reference account.move, sale.order, or any Enterprise model when generating extensions. Note that Enterprise source has a commercial license. Keep it out of public repositories.
How do I give Cursor access to my full Odoo codebase?
Add both the Odoo source directory and your custom addons directory to a multi-root .code-workspace file, as shown in the setup section. For large Odoo installs (Enterprise plus many custom modules), Cursor's indexing may take a few minutes on first open. You can speed this up by excluding test/, doc/, and static/ directories in .cursorignore. This keeps the indexed codebase focused on actual model and view code, which is what the agent needs most.
Is vibe coding Odoo safe for production?
The output is safe if your review process is. Cursor generates plausible Odoo code fast, and that speed increases the risk of shipping something you didn't fully read. Two practices keep it production-safe: first, always run pylint-odoo on generated files before committing. Second, write at least smoke-test coverage for any new model (create a record, assert required fields, trigger computed fields). The .cursorrules file enforces structural correctness, but domain logic requires your judgment. Treat Cursor as a fast junior developer: capable, but needs review before anything goes live.
Start Building Odoo Modules with Cursor
Drop the .cursorrules file in your project, describe what you need, and let Cursor handle the boilerplate. When you're ready to ship, push to OEC.sh and it goes live.
Related Resources
Vibe Coding for Odoo
The complete guide to AI-assisted Odoo development with all major tools.
Odoo GitLab CI/CD
Run Odoo tests automatically before deploying your modules.
Deploy Odoo Guide
Step-by-step guide to deploy Odoo on any cloud provider.
OEC.sh Pricing
Plans that include git deploy, SSH access, and custom addons paths.