Skip to main content
Deep Dive

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.

18 min read
Updated April 2026
Odoo 17/18/19

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

Terminalbash
# 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-commit

Open Cursor with the workspace pointing to both directories so the agent can cross-reference base Odoo code when you work on your custom modules:

.code-workspacejson
{
  "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:

Terminalbash
pip install python-lsp-server pylsp-mypy

Add this to .cursor/settings.json in your project:

.cursor/settings.jsonjson
{
  "python.defaultInterpreterPath": ".venv/bin/python",
  "python.analysis.extraPaths": [
    "~/odoo-src",
    "./custom-addons"
  ]
}

requirements.txt for a typical Odoo dev setup

requirements.txttext
# 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.4

Writing .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.

.cursorrulestext
# .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:

Cursor agent prompttext
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 times

What Cursor generates (reviewed and corrected)

models/project_resource_booking.pypython
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:

Check PostgreSQL extensionsql
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

Cursor agent prompttext
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_booking

Generated views (production-ready)

views/project_resource_booking_views.xmlxml
<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:

Error outputtext
psycopg2.errors.UndefinedFunction: function gist_tstz_consistent(internal,
timestamp with time zone, smallint, oid, internal) does not exist

Cursor Chat prompt:

Cursor Chattext
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.

Cursor Chattext
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 shellbash
./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.

Cursor Chattext
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:

Cursor agent prompttext
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:

Cursor agent prompttext
Generate ir.model.access.csv for project.resource.booking giving
base.group_user full CRUD access.

Output:

security/ir.model.access.csvcsv
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,1

6. 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:

Terminalbash
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 main

OEC.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.

Deploy flowtext
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 running

To 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
Try OEC.sh Free

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.