Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Preface

This documentation covers mxcli, a command-line tool for reading, querying, and modifying Mendix application projects, and MDL (Mendix Definition Language), the SQL-like language it uses. Start with the Vision to understand where this project is headed. The material is organized into ten parts:

  • Part I: Tutorial walks you through installation, exploring a project, making your first changes, and integrating with AI assistants.
  • Part II: MDL by Example presents complete, self-contained examples – a CRM module, REST integration, data import, master-detail pages, validation, security, and more.
  • Part III: Migration covers AI-assisted migration of existing applications to Mendix – assessment, transformation, generation, data migration, and validation.
  • Part IV: The MDL Language is a comprehensive guide to MDL syntax – domain models, microflows, pages, security, navigation, workflows, and business events.
  • Part V: Project Tools covers code navigation, catalog queries, linting, testing, external SQL, and Docker integration.
  • Part VI: IDE Integration describes the VS Code extension, LSP server, and mxcli init project setup.
  • Part VII: Go Library documents the Go API for programmatic access to Mendix projects.
  • Part VIII: MDL Statement Reference provides detailed syntax and examples for every MDL statement.
  • Part IX: Architecture and Internals explains the MPR file format, parser pipeline, and catalog system for contributors.
  • Part X: Appendixes includes quick reference tables, reserved words, error messages, and a glossary.

You can read the Tutorial sequentially or jump directly to the section relevant to your task. The Statement Reference (Part VI) is designed for lookup rather than linear reading.

Vision

mxcli and MDL exist to make Mendix development accessible to coding agents, automation pipelines, and developers who prefer text-based tools. The long-term goal is a complete alternative to Mendix Studio Pro that runs headless, integrates with AI workflows, and never requires leaving VS Code.

Coding Agents First

The primary audience for mxcli is not a human typing commands – it is a coding agent (Claude Code, GitHub Copilot, OpenCode, Cursor, Windsurf, or similar) that reads, reasons about, and modifies Mendix projects autonomously. Everything in mxcli is designed with this in mind:

  • MDL as machine-readable output. Every DESCRIBE command produces valid MDL that can be fed back as input. Agents can read a microflow, modify it, and write it back without lossy format conversions.
  • Skills and context files. mxcli init generates structured skill files that teach agents MDL syntax, common patterns, and project conventions – reducing hallucination and retry loops.
  • Headless operation. mxcli is a single static binary with no GUI, no daemon, and no login. It runs in CI pipelines, containers, and sandboxed agent environments without setup friction.

QA Feedback in the Agentic Loop

Agents produce better results when they can evaluate their own output. mxcli provides the tooling to close this feedback loop:

  • mxcli check validates MDL syntax and catches anti-patterns before changes are applied.
  • mxcli lint runs 40+ rules across the full project, surfacing issues an agent can fix in the same session.
  • mxcli docker build and mx check compile and validate the project against the real Mendix runtime, catching structural errors that static analysis misses.
  • mxcli test runs microflow tests inside a live Mendix container, giving agents pass/fail signals on functional correctness.

Together, these tools let an agent write MDL, validate it, fix issues, and verify the result – all without human intervention.

VS Code as the Review Surface

While agents do the building, humans need to review and approve the result. The VS Code extension (vscode-mdl) provides the visual tools to do this without leaving the editor:

  • Syntax highlighting, diagnostics, and completion for .mdl files.
  • Hover and go-to-definition for navigating the Mendix model from code.
  • Context menu commands to run, check, and lint MDL directly from the editor.
  • Terminal link integration so that entity names, microflow references, and error locations in terminal output are clickable.

The goal is that a developer can review an agent’s changes, run the app, inspect the result, and approve – all within VS Code.

Long-Term Roadmap

The end state is a complete headless development environment for Mendix:

CapabilityStatusDescription
Model read/writeShippedFull domain model, microflow, page, security, navigation, workflow support
Validation and lintingShippedmxcli check, mxcli lint, 40+ rules, SARIF output for CI
Build and runShippedmxcli docker build/run with hot reload
TestingShipped.test.mdl microflow tests with Docker runtime
VS Code extensionShippedLSP, diagnostics, completion, hover, go-to-definition
External SQLShippedQuery PostgreSQL, Oracle, SQL Server; import data
Mendix MarketplacePlannedInstall and manage Marketplace modules from the CLI
Mendix Cloud deploymentPlannedDeploy to Mendix Cloud environments without Studio Pro
Mendix CatalogPlannedPublish and consume APIs via the Mendix Catalog
Visual page previewPlannedPreview pages in VS Code without running the app
Full Studio Pro parityLong-termCover remaining metamodel domains (REST, OData, consumed services, etc.)

The measure of success is simple: a team should be able to develop, test, review, and deploy a Mendix application without ever opening Studio Pro.

What is mxcli?

mxcli is a command-line tool that enables developers and AI coding assistants to read, understand, and modify Mendix application projects. Mendix projects are stored in binary .mpr files that cannot be read or edited as text. mxcli bridges this gap by providing a text-based interface using MDL (Mendix Definition Language), a SQL-like syntax for querying and manipulating Mendix models.

How It Works

┌─────────────────┐     ┌──────────────┐     ┌─────────────────┐
│   Developer     │     │    mxcli     │     │ Mendix Project  │
│   or AI Agent   │────>│  (MDL REPL)  │────>│   (.mpr file)   │
│                 │ MDL │              │     │                 │
│ "Create a       │     │ Parses MDL   │     │ Creates actual  │
│  Customer       │     │ Validates    │     │ entities,       │
│  entity..."     │     │ Executes     │     │ microflows,     │
└─────────────────┘     └──────────────┘     │ pages, etc.     │
                                             └─────────────────┘

A developer or AI agent writes MDL statements. mxcli parses and validates them, then applies the changes directly to the .mpr project file. The modified project can then be opened in Mendix Studio Pro as usual.

Important: Do not edit a project with mxcli while it is open in Studio Pro. Studio Pro maintains in-memory caches that cannot be updated externally, and concurrent edits will cause errors. Close the project in Studio Pro first, run mxcli, then re-open the project.

Key Capabilities

  • Project exploration – list modules, entities, microflows, pages; describe any element in MDL; full-text search across all strings and source definitions.
  • Code navigation – find callers, callees, references, and impact analysis for any project element.
  • Model modification – create and alter entities, microflows, pages, security rules, navigation, workflows, and more using MDL scripts.
  • Catalog queries – SQL-based querying of project metadata (entity counts, microflow complexity, widget usage, cross-references).
  • Linting and reports – 40+ built-in lint rules with SARIF output for CI; scored best-practices reports.
  • Testing – test microflows using MDL syntax with javadoc-style annotations.
  • AI assistant integration – works with Claude Code, GitHub Copilot, OpenCode, Cursor, Continue.dev, Windsurf, and Aider. mxcli init sets up project configuration, skills, and a Dev Container for sandboxed AI development.
  • VS Code extension – syntax highlighting, diagnostics, code completion, hover, go-to-definition, and context menu commands for .mdl files.
  • External SQL – connect to PostgreSQL, Oracle, or SQL Server; query external databases; import data into a running Mendix application.

Supported Environments

ItemDetails
Mendix versionsStudio Pro 8.x, 9.x, 10.x, 11.x
MPR formatsv1 (single .mpr file) and v2 (.mpr + mprcontents/ folder)
PlatformsLinux, macOS, Windows (amd64 and arm64)
DependenciesNone – mxcli is a single static binary with no runtime dependencies

What is MDL?

MDL (Mendix Definition Language) is a text-based, SQL-like language for describing and manipulating Mendix application models. It provides a human-readable representation of Mendix project elements – entities, microflows, pages, security rules, and more – at the same abstraction level as the visual models in Studio Pro.

Why a Text-Based Language?

Mendix projects are stored in a binary format (.mpr files containing BSON documents in SQLite). While Studio Pro provides a visual interface, there is no standard text format for Mendix models. This creates problems for:

  • AI assistants that need to read and generate model elements
  • Code review of changes to Mendix projects
  • Scripting and automation of repetitive modeling tasks
  • Documentation of project structure and design patterns

MDL addresses these problems by providing a syntax that is both human-readable and machine-parseable.

Characteristics of MDL

MDL draws on three well-understood languages: SQL for its CREATE/ALTER/DROP/SHOW verbs and set-oriented queries, and Ada and PL/SQL for its readable, block-structured statement bodies. The result is a syntax that is immediately recognisable to anyone who has written database or procedural code.

  • SQL-like syntax – uses familiar keywords like CREATE, ALTER, DROP, SHOW, DESCRIBE, GRANT.
  • Declarative – you describe what the model should look like, not how to construct it step by step.
  • Complete – covers domain models, microflows, pages, security, navigation, workflows, and business events.
  • Validated – mxcli can check MDL scripts for syntax errors and reference validity before applying them.

Token Efficiency

When working with AI coding assistants, context window size is a critical constraint. MDL’s compact syntax uses significantly fewer tokens than equivalent JSON model representations:

RepresentationTokens for a 10-entity module
JSON (raw model)~15,000–25,000 tokens
MDL~2,000–4,000 tokens
Reduction5–10x fewer tokens

Fewer tokens means lower API costs, more application context fits in a single prompt, and AI assistants produce more accurate output because there is less noise in their input.

Example: JSON vs MDL

A simple entity definition illustrates the difference.

JSON representation (~180 tokens):

{
  "entity": {
    "name": "Customer",
    "documentation": "Customer master data",
    "attributes": [
      {"name": "Name", "type": {"type": "String", "length": 200}},
      {"name": "Email", "type": {"type": "String", "length": 200}}
    ]
  }
}

MDL representation (~35 tokens):

/** Customer master data */
CREATE PERSISTENT ENTITY Sales.Customer (
    Name: String(200),
    Email: String(200)
);

The MDL version conveys the same information in roughly one-fifth the space, and its structure is immediately recognizable to anyone familiar with SQL DDL.

What MDL Covers

MDL provides statements for the following areas of a Mendix project:

AreaExample Statements
Domain modelCREATE ENTITY, ALTER ENTITY, CREATE ASSOCIATION
MicroflowsCREATE MICROFLOW, CREATE NANOFLOW
PagesCREATE PAGE, CREATE SNIPPET, ALTER PAGE
SecurityCREATE MODULE ROLE, GRANT, REVOKE
NavigationALTER NAVIGATION
WorkflowsCREATE WORKFLOW
Business eventsCREATE BUSINESS EVENT SERVICE
Project queriesLIST MODULES, DESCRIBE ENTITY, SEARCH
CatalogREFRESH CATALOG, SELECT FROM CATALOG

See Part II (The MDL Language) for a complete guide, or Part VI (MDL Statement Reference) for detailed syntax of each statement.

Design Principles

mxcli is not a thin wrapper over the .mpr format. It is a deliberate bet on a particular way of letting coding agents build Mendix applications: the agent writes a compact, human-readable DSL, and mxcli’s deterministic layer turns that DSL into the many low-level mutations, validations, and tool calls a real model change requires. The principles below explain the design choices that follow from that bet, and why they differentiate mxcli from driving Studio Pro’s tools directly.

Token efficiency

An agent authoring against mxcli emits one larger MDL script, not a long back-and-forth of small, chatty tool calls. MDL itself is dense – a module that takes 15,000–25,000 tokens as raw model JSON is 2,000–4,000 tokens as MDL, a 5–10x reduction (see What is MDL?). But the larger win is orchestration overhead: one script is one agent action and one summarized result, so there are fewer model turns, lower API cost, and faster execution than emitting each individual document edit as its own turn. The MCP Backend cost analysis traces this in detail – a ~20-statement script that expands to ~60 low-level tool calls still costs the agent one turn when it runs through mxcli.

Complexity belongs in the deterministic layer

A single MDL statement maps deterministically to whatever it takes to realise it: multiple BSON documents on the file backend, or a fixed choreography of MCP tool calls against a running Studio Pro. create association, for example, expands to reference resolution, the write itself, and a validation pass. The LLM never has to orchestrate that – it states intent in MDL, and mxcli’s deterministic code carries the fan-out, ordering, and read-modify-write bookkeeping. The agent handles the simple task (say what you want); mxcli handles the complex one (make it valid). This keeps the hard, error-prone mechanics in code that is tested and reproducible rather than in a probabilistic model.

Human-readable DSL

MDL is a domain-specific language inspired by SQL, Ada, and PL/SQL – SQL for its CREATE/ALTER/DROP/SHOW verbs and set-oriented queries, Ada and PL/SQL for its readable block structure. That familiar, structured syntax means LLM-generated scripts are easy for a human to review, audit, and author – both in documentation and in skills. A diff of an MDL script reads like a diff of DDL, not an opaque binary or a wall of JSON. Readable code is reviewable code, and reviewable changes are the ones a team can safely let an agent make.

Headless agentic loop

Everything an agent needs to build and test a Mendix application is available without ever opening Studio Pro. mxcli is a single static binary; it reads and writes .mpr files directly, validates with mxcli check and the real Mendix build tools, runs microflow tests in a container, and analyses the model – all from the command line. Studio Pro becomes an optional visual review surface, not a required step in the loop. See the Vision for the full roadmap toward Studio-Pro-free development.

Understanding large applications

mxcli builds a queryable catalog of project metadata and a dependency graph on top of it, both exposed as ordinary SQL tables. An agent explores an unfamiliar app the same way it would explore a database – SELECT over CATALOG.ENTITIES, CATALOG.REFS, or the CATALOG.graph_* views to find god nodes, module coupling, dependency cycles, and dead code. No bespoke traversal API to learn; complex applications become explorable through straightforward queries. See Graph Analysis and Catalog Use Cases.

Precise skills

Because MDL is a concrete syntax, skill files can embed specific, executable examples of exactly the MDL an agent should produce – not vague prose about how Mendix concepts fit together. “Generate this shape of create page for a master-detail view” is a copyable example; the agent adapts one worked case rather than inferring structure from description. Concrete examples in skills yield more reliable, accurate output and fewer retry loops.

Power with safety

Coding agents driving mxcli are powerful – they can generate scripts in bulk, run automated tests, and rewrite large parts of a model. That power requires appropriately scoped privileges. mxcli init provisions a Dev Container that bounds exactly what the agent can see, do, and modify: it runs against the project files and nothing else, with the toolchain pre-installed and the host environment out of reach. Sandboxing is the default recommended way to run an agent, not an afterthought. See Dev Container Setup.

Deterministic verification

mxcli check and mxcli lint let the agent verify its own output against deterministic rules – syntax, references, anti-patterns, security and architecture policies – with no LLM judgement involved. This is an explicit design choice: correctness checks stay in the deterministic layer so the feedback loop is fast, reproducible, and trustworthy. An agent can write MDL, get an objective pass/fail, fix, and re-check in the same session without a human – and without asking a model to grade a model. Deeper gates (mxcli docker check, mxcli test) extend the same principle to the real Mendix build and runtime.

Longer-term: smaller local models

Generating code in a constrained DSL is inherently simpler for an LLM than orchestrating a stateful sequence of tool calls – there is one grammar to satisfy, not an evolving protocol to reason about. That simplicity opens a path to smaller, cheaper, and eventually local models for Mendix authoring. A DSL grammar can be integrated into an LLM via grammar-constrained decoding, so the model can only emit tokens that form valid MDL. The deterministic layer already absorbs the complexity (see above); the DSL boundary is what makes constraining the model’s output tractable in the first place.

The Language Poster

All of mxcli and MDL on one page: the idea, getting started, the CLI map, exploring and querying a project, domain models, microflows, pages, security, running and testing, quality gates, skills, custom widgets, the Marketplace, runtime insight, and working alongside Studio Pro.

Open the interactive poster – zoom into any panel, or step through it as a 19-panel tour.

The mxcli language poster

A full-resolution PNG (5498×3380) is available for printing.

Mendix Concepts for Newcomers

This page explains the core Mendix concepts you’ll encounter when using mxcli. If you’re familiar with web frameworks, relational databases, or backend development, most of these will map to things you already know.

The Big Picture

A Mendix application is a model-driven app. Instead of writing code in files, developers build applications visually in Studio Pro (the Mendix IDE). The entire application model – data structures, logic, UI, security – is stored in a single binary file called an MPR (Mendix Project Resource).

mxcli lets you read and modify that MPR file using text commands, without opening Studio Pro.

Modules

A module is the top-level organizational unit, like a package in Go or a namespace in C#. Each module has its own:

  • Domain model (data structures)
  • Microflows and nanoflows (logic)
  • Pages (UI)
  • Enumerations (constant sets)
  • Security settings

A typical project has a few custom modules (Sales, Admin, Integration) plus system modules provided by the platform (System, Administration).

LIST MODULES;

Domain Model

The domain model is the data layer of a module. If you know relational databases, the mapping is straightforward:

Mendix ConceptRelational EquivalentMDL Syntax
EntityTableCREATE ENTITY
AttributeColumnInside entity definition
AssociationForeign key / Join tableCREATE ASSOCIATION
GeneralizationTable inheritanceEXTENDS
EnumerationEnum / Check constraintCREATE ENUMERATION

Entities

An entity defines a data type. There are several kinds:

  • Persistent – stored in the database (the default and most common)
  • Non-persistent – exists only in memory during a user session, useful for form state or temporary calculations
  • View – backed by an OQL query, like a database view
CREATE PERSISTENT ENTITY Sales.Customer (
    Name: String(200) NOT NULL,
    Email: String(200),
    IsActive: Boolean DEFAULT true
);

Associations

An association is a relationship between two entities. Think of it as a foreign key.

  • Reference – many-to-one or one-to-one (a foreign key column on the “from” entity)
  • ReferenceSet – many-to-many (a join table under the hood)
CREATE ASSOCIATION Sales.Order_Customer
    FROM Sales.Order TO Sales.Customer
    TYPE Reference;

Generalization

Entities can inherit from other entities using generalization (the EXTENDS keyword). The child entity gets all parent attributes. This is commonly used with system entities like System.Image (for file uploads) or System.User (for user accounts).

CREATE PERSISTENT ENTITY Sales.ProductImage EXTENDS System.Image (
    Caption: String(200)
);

Microflows and Nanoflows

Microflows are the server-side logic of a Mendix app. They’re visual flowcharts in Studio Pro, but in MDL they read like imperative code with activities:

  • Retrieve data from the database
  • Create, Change, Commit, Delete objects
  • Call other microflows or external services
  • If/Else branching, Loop iteration
  • Show Page, Log Message, Validation Feedback
CREATE MICROFLOW Sales.CreateOrder(
    DECLARE $Customer: Sales.Customer
)
RETURN Boolean
BEGIN
    CREATE $Order: Sales.Order (
        OrderDate = [%CurrentDateTime%],
        Status = 'Draft'
    );
    CHANGE $Order (
        Sales.Order_Customer = $Customer
    );
    COMMIT $Order;
    RETURN true;
END;

Nanoflows are the client-side equivalent. They run in the browser (or native mobile app) and are useful for offline-capable logic and low-latency UI interactions. They have the same syntax but fewer available activities (no database transactions, no direct service calls).

Pages

Pages define the user interface. A page has:

  • A layout – the outer frame (header, sidebar, footer) shared across pages
  • Widgets – the UI components inside the page

Widgets are nested in a tree. Common widget types:

WidgetPurposeAnalogy
DataViewDisplays one objectA form
DataGridDisplays a list as a tableAn HTML table with sorting/search
ListViewDisplays a list with custom layoutA repeating template
TextBoxText input bound to an attributeAn <input> field
ButtonTriggers an actionA <button>
ContainerGroups other widgetsA <div>
LayoutGridResponsive column layoutCSS grid / Bootstrap row
CREATE PAGE Sales.CustomerOverview
(
    Title: 'Customers',
    Layout: Atlas_Core.Atlas_Default
)
{
    DATAGRID dgCustomers (DataSource: DATABASE Sales.Customer) {
        COLUMN (Attribute: Name, Caption: 'Name')
        COLUMN (Attribute: Email, Caption: 'Email')
        COLUMN (Attribute: IsActive, Caption: 'Active')
    }
};

Snippets

A snippet is a reusable page fragment. You define it once and embed it in multiple pages using a SnippetCall. Think of it as a component or partial template.

Security

Mendix uses a role-based access control model:

  1. Module roles are defined per module (e.g., Sales.Admin, Sales.User)
  2. User roles are defined at the project level and aggregate module roles
  3. Access rules control what each module role can do with entities (CREATE, READ, WRITE, DELETE) and which microflows/pages they can access
CREATE MODULE ROLE Sales.Manager;

GRANT CREATE, READ, WRITE ON Sales.Customer TO Sales.Manager;
GRANT EXECUTE ON MICROFLOW Sales.CreateOrder TO Sales.Manager;
GRANT VIEW ON PAGE Sales.CustomerOverview TO Sales.Manager;

Navigation profiles define how users move through the app. There are profiles for responsive web, tablet, phone, and native mobile. Each profile has:

  • A home page (the landing page after login)
  • A menu with items that link to pages or microflows

Workflows

Workflows model long-running processes with human tasks. Think of them as state machines for approval flows, onboarding processes, or multi-step procedures. A workflow has:

  • User tasks – steps that require human action
  • Decisions – branching based on conditions
  • Parallel splits – concurrent paths

Workflows complement microflows: microflows handle immediate logic, workflows handle processes that span hours or days.

How It All Fits Together

Project
├── Module: Sales
│   ├── Domain Model
│   │   ├── Entity: Customer (Name, Email, IsActive)
│   │   ├── Entity: Order (OrderDate, Status)
│   │   └── Association: Order_Customer
│   ├── Microflows
│   │   ├── CreateOrder
│   │   └── ApproveOrder
│   ├── Pages
│   │   ├── CustomerOverview
│   │   └── OrderEdit
│   ├── Enumerations
│   │   └── OrderStatus (Draft, Active, Closed)
│   └── Security
│       ├── Module Role: Manager
│       └── Module Role: Viewer
├── Module: Administration
│   └── ...
└── Navigation
    └── Responsive profile → Home: Sales.CustomerOverview

In mxcli, you can explore this structure with:

DESCRIBE STRUCTURE DEPTH 2;

What’s Next

Document Conventions

This page describes the formatting and notation conventions used throughout this documentation.

Code Examples

MDL examples use sql code fencing because MDL’s syntax is SQL-like and this provides appropriate syntax highlighting:

CREATE PERSISTENT ENTITY MyModule.Customer (
    Name: String(200) NOT NULL
);

Go code examples use go code fencing:

reader, err := modelsdk.Open("/path/to/app.mpr")
defer reader.Close()

Shell commands use bash code fencing:

mxcli -p app.mpr -c "LIST MODULES"

Syntax Notation

When describing the syntax of MDL statements, the following conventions apply:

NotationMeaning
UPPERCASEKeywords – type them exactly as shown
lowercaseUser-provided values (names, expressions, types)
[brackets]Optional clause – may be omitted
...Repetition – the preceding element may appear multiple times
`ab`
( )Grouping – used to clarify precedence in syntax descriptions

For example, the notation:

CREATE [PERSISTENT] ENTITY module.name (
    attribute_name: type [NOT NULL] [, ...]
);

means: CREATE and ENTITY are required keywords; PERSISTENT is optional; module.name is a user-provided qualified name; each attribute has a name and type, with an optional NOT NULL constraint; and additional attributes may follow, separated by commas.

Cross-References

References to MDL statements link to their detailed pages in Part VI (MDL Statement Reference) using the format “See CREATE ENTITY” or “See GRANT”. References to conceptual explanations link to the relevant section in Part II (The MDL Language).

Terminology

Mendix-specific terms such as “entity”, “microflow”, “nanoflow”, “module”, and “domain model” follow standard Mendix terminology. See the Glossary for definitions.

5-Minute Quickstart

Get from zero to modifying a Mendix project in 5 minutes. No prior MDL knowledge needed.

1. Install mxcli

Option A: Zero install (Playground)

Open in GitHub Codespaces

Open the mxcli Playground in a Codespace – mxcli, a sample project, and example scripts are pre-installed. Skip to step 3.

Option B: Binary download

Release assets are raw binaries named mxcli-<os>-<arch> (nothing to extract). Use the rolling nightly build while mxcli is alpha:

# macOS / Linux
curl -fsSL -o mxcli \
  https://github.com/mendixlabs/mxcli/releases/download/nightly/mxcli-linux-amd64
chmod +x mxcli && sudo mv mxcli /usr/local/bin/

Option C: Build from source (Go + Make)

git clone https://github.com/mendixlabs/mxcli.git && cd mxcli && make build
# binary at ./bin/mxcli  (go install @latest doesn't work — see Installation)

Verify: mxcli --version should print the version number.

See Installation for all options including Dev Containers.

2. Open your project

mxcli -p /path/to/your-app.mpr

You’ll see:

Connected to: your-app.mpr (Mendix 11.6.3)
MDL REPL - Mendix Definition Language
mdl>

Don’t have a Mendix project? Create one with mxcli new test-app --version 11.8.0 — this downloads MxBuild, creates a blank project, and sets up all tooling automatically.

3. Explore what’s there

-- List all modules
LIST MODULES;

-- List entities in a module
LIST ENTITIES IN MyFirstModule;

-- See the full structure at a glance
DESCRIBE STRUCTURE;

4. Create something

-- Create a new entity
CREATE PERSISTENT ENTITY MyFirstModule.Customer (
  Name: String(200) NOT NULL,
  Email: String(200),
  IsActive: Boolean DEFAULT true
);

Expected output:

Created entity: MyFirstModule.Customer

5. Verify it works

-- See what you created
DESCRIBE ENTITY MyFirstModule.Customer;

Output:

@Position(100, 100)
create or modify persistent entity MyFirstModule.Customer (
  Name: String(200) not null,
  Email: String(200),
  IsActive: Boolean default true
);

Open the project in Studio Pro – your entity is there.

6. Create a microflow

CREATE MICROFLOW MyFirstModule.ACT_DeactivateCustomer (
  $Customer: MyFirstModule.Customer
)
BEGIN
  CHANGE $Customer (IsActive = false);
  COMMIT $Customer;
END;

7. Validate

# Check your project for errors (from a separate terminal)
mxcli check -p /path/to/your-app.mpr

No errors? You’re done. Open in Studio Pro and everything is there.

What’s next?

I want to…Read…
Explore my project deeperSHOW Commands
Create pages with widgetsCreating a Page
Use AI to generate codeClaude Code Integration
Set up for a teamSkills and CLAUDE.md
See everything mxcli can doCapabilities Overview
Customize AI generationCustomizing AI Generation

Common issues

“mx command not available” – Install mxbuild for validation:

mxcli setup mxbuild -p your-app.mpr

“CGO not available” – mxcli uses pure Go SQLite. No C compiler needed. If you see CGO errors, ensure you’re using the official pre-built binary or a make build from source.

“refusing to write …: Studio Pro has this project open” – Close the project in Studio Pro before running mxcli write commands, then reopen it. Studio Pro does not reload the model from disk, so its next save would silently discard mxcli’s changes. If Studio Pro is not running, the .mpr.lock was left behind by a crash: delete it, or pass --force to exec.

Setting Up

Before you can start exploring and modifying Mendix projects from the command line, you need three things:

  1. mxcli installed on your machine
  2. A Mendix project (an .mpr file) to work with
  3. A feel for the REPL, the interactive shell where you’ll spend most of your time

This chapter walks you through all three.

Installation methods

There are five ways to get started with mxcli:

  • Playground – open the mxcli Playground in a GitHub Codespace. Zero install, runs in your browser with a sample Mendix project, tutorials, and example scripts. Best way to try mxcli for the first time.
  • mxcli new (recommended for new projects) – run mxcli new MyApp --version 11.8.0 to create a new Mendix project from scratch with all tooling and Dev Container configured. One command does everything: downloads MxBuild, creates the project, sets up AI tools, and installs the correct mxcli binary.
  • Binary download – grab a pre-built binary from GitHub Releases. Quickest path if you want to use mxcli on your own project.
  • Build from source – clone the repo and run make build. Useful if you want the latest unreleased changes or plan to contribute.
  • Dev Container (recommended for existing projects) – run mxcli init on your Mendix project, open it in VS Code, and reopen in the container. This gives you mxcli, a JDK, Docker-in-Docker, and Claude Code all pre-configured in a sandboxed environment. This is the recommended approach, especially when pairing with AI coding assistants.

The next few pages cover each method, then walk you through opening a project and using the REPL.

Alpha software warning. mxcli can corrupt your Mendix project files. Always work on a copy of your .mpr or use version control (Git) before making changes.

Installation

Pick whichever method suits your situation. If you just want to try mxcli without installing anything, start with the Playground. If you’re planning to use mxcli on your own project with an AI coding assistant, skip to the Dev Container section.

Playground (zero install)

The fastest way to try mxcli. The mxcli Playground is a GitHub repository with a pre-configured Mendix project, example scripts, and tutorials. Open it in a Codespace and start using mxcli immediately – nothing to install on your machine.

Open in GitHub Codespaces

The Codespace comes with mxcli, a JDK, Docker-in-Docker, Claude Code, and a sample Mendix 11.x project ready to explore and modify. It includes:

  • 5 example scripts – explore, create entities, microflows, pages, and security
  • Step-by-step tutorials – from first steps through linting and testing
  • AI tool configs – pre-configured for Claude Code, GitHub Copilot, OpenCode, Cursor, Windsurf, Continue.dev, and Aider

Once the Codespace is running:

./mxcli -p App.mpr -c "DESCRIBE STRUCTURE"          # Explore the project
./mxcli exec scripts/01-explore.mdl -p App.mpr   # Run an example script
./mxcli                                           # Start interactive REPL

When you’re ready to work on your own Mendix project, use one of the installation methods below.

Binary download

Pre-built binaries are published for Linux, macOS, and Windows on both amd64 and arm64. Release assets are raw binaries named mxcli-<os>-<arch> (Windows: .exe) — there is nothing to extract.

While mxcli is fast-moving alpha, use the rolling nightly build (new features land there before a tagged release); pin a vX.Y.Z release for reproducibility.

# Linux/macOS — nightly (recommended for now)
curl -fsSL -o mxcli \
  https://github.com/mendixlabs/mxcli/releases/download/nightly/mxcli-linux-amd64
chmod +x mxcli && sudo mv mxcli /usr/local/bin/

# ...or pin a specific release
curl -fsSL -o mxcli \
  https://github.com/mendixlabs/mxcli/releases/download/v0.16.0/mxcli-darwin-arm64
chmod +x mxcli && sudo mv mxcli /usr/local/bin/

Assets: mxcli-linux-amd64, mxcli-linux-arm64, mxcli-darwin-amd64, mxcli-darwin-arm64, mxcli-windows-amd64.exe, mxcli-windows-arm64.exe. On Windows, download the .exe and add its folder to your PATH. You can browse them on the GitHub Releases page.

go install github.com/mendixlabs/mxcli/cmd/mxcli@latest does not work: the ANTLR parser is generated at build time and isn’t committed, so a go install from the module source fails. Use a pre-built binary, or Build from source below (which runs make grammar).

Build from source

Building from source requires Go 1.24 or later and Make. No C compiler is needed – mxcli uses a pure-Go SQLite driver.

git clone https://github.com/mendixlabs/mxcli.git
cd mxcli
make build

The binary lands at ./bin/mxcli. You can copy it to a directory on your PATH or run it directly:

./bin/mxcli --version

New project from scratch

If you don’t have a Mendix project yet, mxcli new creates one with everything configured:

mxcli new MyApp --version 11.8.0

This single command:

  1. Downloads MxBuild for the specified Mendix version
  2. Creates a blank Mendix project (App.mpr)
  3. Sets up AI tooling (.claude/, skills, AGENTS.md)
  4. Configures a Dev Container (.devcontainer/)
  5. Runs one build, so the action stubs MxBuild regenerates are already settled
  6. Puts an mxcli binary in the project — a hard link to the one you ran on Linux, a downloaded Linux build on macOS/Windows (the container needs a Linux ELF)

Open the resulting MyApp/ folder in VS Code and click “Reopen in Container” — you’re ready to go.

Options:

mxcli new MyApp --version 10.24.0 --output-dir ./projects/my-app
mxcli new MyApp --version 11.8.0 --skip-init    # Skip AI tooling setup
mxcli new MyApp --version 11.8.0 --skip-build   # Skip the first build

Dev Container for existing projects

For an existing Mendix project, mxcli init adds AI tooling and a Dev Container configuration.

Here’s how to set it up:

Step 1: Install mxcli using one of the methods above (you need it locally to run init).

Step 2: Run mxcli init on your Mendix project:

mxcli init /path/to/my-mendix-project

This creates a .devcontainer/ folder (along with skill files, agent configs, and other goodies) inside your project directory.

Step 3: Open the project folder in VS Code and click “Reopen in Container” when prompted (or use the Command Palette: Dev Containers: Reopen in Container).

VS Code will build and start the container. This takes a minute or two the first time.

What’s inside the Dev Container

The container comes with everything you need pre-installed:

ComponentWhat it’s for
mxcliThe CLI itself, copied into the project
JDK 21 (Adoptium)Required by MxBuild for project validation
Docker-in-DockerRunning Mendix apps locally with mxcli docker run
Node.jsPlaywright testing support
PostgreSQL clientDatabase connectivity for demo data
Claude CodeAI coding assistant (auto-installed on container creation)

Once the container is running, mxcli is ready to use – no further setup needed.

Specifying AI tools

By default, mxcli init configures for Claude Code. You can target other tools too:

# Cursor only
mxcli init --tool cursor /path/to/my-mendix-project

# Multiple tools
mxcli init --tool claude --tool cursor /path/to/my-mendix-project

# Everything
mxcli init --all-tools /path/to/my-mendix-project

Run mxcli init --list-tools to see all supported tools.

Verify your installation

Whichever method you used, confirm that mxcli is working:

mxcli --version

You should see version and build information printed to the terminal. If you get a “command not found” error, double-check that the binary is on your PATH.

You’re ready to open a project.

Opening Your First Project

mxcli works with Mendix project files – the .mpr files that Mendix Studio Pro creates. Let’s open one.

Back up first. mxcli is alpha software and can corrupt project files. Before you start, either make a copy of your .mpr file or make sure the project is under version control (Git). This isn’t just a disclaimer – take it seriously.

The -p flag

The most common way to point mxcli at a project is with the -p flag:

mxcli -p /path/to/app.mpr -c "LIST MODULES"

This opens the project in read-only mode, runs the command, and exits. The -p flag works with all mxcli subcommands.

-c takes several statements separated by ;. Like mxcli exec, it stops at the first statement that fails, prints statement 2 of 3: … and how many later statements were not run, and exits non-zero; add --continue-on-error to run every statement and report each failure. An empty -c "" is an error: it never opens the interactive REPL, so a script that generates an empty command list cannot hang waiting on input.

Opening in the REPL

You can also open a project from inside the interactive REPL:

# Start the REPL, then open a project
mxcli
OPEN PROJECT '/path/to/app.mpr';
LIST MODULES;

Or pass -p when launching the REPL so it opens the project immediately:

mxcli -p /path/to/app.mpr
-- Project is already open, start working
LIST MODULES;

When you provide -p, the project stays open for the duration of your REPL session. You don’t need to pass it again for each command.

Read-only vs read-write

By default, mxcli opens projects in read-only mode. This is safe for exploration – you can browse modules, describe entities, search through the project, and run catalog queries without risk.

When you execute a command that modifies the project (like CREATE ENTITY), mxcli automatically upgrades to read-write mode. You’ll see a confirmation message when this happens.

MPR format auto-detection

Mendix projects come in two formats:

  • v1: A single .mpr SQLite database file (Mendix versions before 10.18)
  • v2: An .mpr metadata file plus an mprcontents/ folder with individual documents (Mendix 10.18 and later)

You don’t need to worry about which format your project uses. mxcli detects the format automatically and handles both transparently. Just point it at the .mpr file either way.

Working in a Dev Container

If you’re using the Dev Container setup from the previous page, your project is already mounted in the container. The typical path is:

mxcli -p app.mpr

The Dev Container sets the working directory to your project root, so relative paths work naturally.

Quick sanity check

Once you have a project open, try listing the modules:

mxcli -p app.mpr -c "LIST MODULES"

You should see a table of module names. If you see your project’s modules listed, everything is working and you’re ready to explore.

Next up: the REPL, where the real fun starts.

The REPL

The mxcli REPL is an interactive shell for working with Mendix projects, much like psql is for PostgreSQL or mysql for MySQL. You type MDL statements, press Enter, and see results immediately.

Starting the REPL

Launch it with or without a project:

# Start with a project already loaded
mxcli -p app.mpr

# Start without a project (you can open one later)
mxcli

You’ll see a prompt where you can start typing MDL:

mxcli>

Running commands

Type any MDL statement and press Enter:

LIST MODULES;

Results are printed as formatted tables directly in the terminal.

Multi-line statements

MDL statements end with a semicolon (;). If you press Enter before typing a semicolon, mxcli knows you’re still writing and waits for more input:

CREATE ENTITY MyModule.Customer (
    Name: String(200) NOT NULL,
    Email: String(200),
    IsActive: Boolean DEFAULT true
);

The REPL shows a continuation prompt (.....>) while you’re in a multi-line statement. The statement executes when you type the closing ; and press Enter.

Getting help

Type HELP to see a list of available commands:

HELP;

This prints a categorized overview of all MDL statements – useful when you can’t remember the exact syntax for something.

Command history

Use the up and down arrow keys to scroll through previous commands, just like in any other shell. This is especially handy when you’re iterating on a query or tweaking a CREATE statement.

Exiting

When you’re done, type either:

EXIT;

or:

QUIT;

You can also press Ctrl+D to exit.

One-off commands with -c

You don’t always need an interactive session. The -c flag lets you run a single command and exit:

mxcli -p app.mpr -c "LIST ENTITIES IN MyModule"

This is great for quick lookups and for scripting. The output is pipe-friendly, so you can chain it with other tools:

# Count entities per module
mxcli -p app.mpr -c "LIST MODULES" | tail -n +2 | while read module; do
    echo "$module: $(mxcli -p app.mpr -c "LIST ENTITIES IN $module" | wc -l) entities"
done

Running script files

For anything beyond a quick one-liner, you can put MDL statements in a .mdl file and execute the whole thing:

mxcli -p app.mpr -c "EXECUTE SCRIPT 'setup.mdl'"

Or from the REPL:

EXECUTE SCRIPT 'setup.mdl';

This is how you’ll typically apply larger changes – write the MDL in a file, check the syntax with mxcli check, then execute it.

The TUI (Terminal UI)

If you prefer a more visual experience, mxcli also offers a graphical terminal interface:

mxcli tui -p app.mpr

The TUI gives you a split-pane layout with a project tree, an MDL editor, and output panels. It’s built on top of the same REPL engine, so all the same commands work. This can be a nice middle ground between the bare REPL and opening Studio Pro.

What’s next

Now that you know how to install mxcli, open a project, and use the REPL, you’re ready to start exploring. The next chapter walks through the commands you’ll use most often: SHOW, DESCRIBE, and SEARCH.

Developing on the Web (Claude Code)

You can build a Mendix app with mxcli entirely in the browser — no local CLI, no Studio Pro, no machine setup. Claude Code on the web runs the agent in a cloud container against a GitHub repository; mxcli provisions the whole toolchain inside that container on first run and commits the result so future sessions come back up on their own. It works from a laptop, and from an iPad or phone.

This page walks the entire workflow:

  1. Create a GitHub repository
  2. Get a hub key for browser preview (optional)
  3. Create a Claude Code environment
  4. Start the session with the bootstrap prompt
  5. Iterate — the warm loop, preview, and screenshots

New to how Claude Code on the web works (sessions, environments, network policies)? See Anthropic’s guide: https://code.claude.com/docs/en/claude-code-on-the-web.

1. Create a GitHub repository

Create a new, empty repository on GitHub (private is fine). This is where the app will live and where every Claude Code session runs. You don’t need a template or any starter files — the bootstrap prompt starts from a truly empty repo and runs the current mxcli, so there is nothing to keep up to date.

If you already have a Mendix project in a repo, you can skip the bootstrap prompt and use mxcli init instead (step 4 notes how).

2. Get a hub key for browser preview

Optional, but recommended — it’s what lets you actually see the running app in a browser tab from a web session.

A cloud session has no localhost you can open, so mxcli can reverse-tunnel the running app out to the mxcli hub and give you a public preview URL (see External browser preview). The hosted hub at hub.mxcli.org is GitHub-authenticated, so each preview is private to you — which means you need a per-user hub key:

  1. Open https://hub.mxcli.org/cli in any browser and sign in with GitHub.
  2. Click Create a hub key and copy it.
  3. Keep it for step 3 — you’ll set it as MXCLI_HUB_KEY.

The key is durable (no expiry, survives hub restarts); you set it once and it stays valid until you revoke it from the same page. If you’d rather run your own hub, see mxcli tunnel-hub.

You can skip this step and add the key later — without it, the app still builds and runs in the container; you just won’t have a browser preview URL.

3. Create a Claude Code environment

In Claude Code on the web, create an environment pointed at the repository from step 1. The environment is the reusable container definition for your sessions; configure two things on it:

Environment variables

VariableValueWhy
MXCLI_HUB_KEYthe hub key from step 2mxcli reads it automatically so run --hub registers previews as you. Survives container reaping.

Set the hub key here, on the environment — never in a committed file. It’s a credential, and a gitignored file wouldn’t survive container recycling anyway; environment variables are re-injected into every session, so the environment is the only place it belongs.

Network policy

The bootstrap prompt downloads a few things on first run, so the environment’s network policy must allow outbound HTTPS to:

  • github.com / objects.githubusercontent.com — the prebuilt mxcli binary from GitHub Releases.
  • Mendix’s CDN — MxBuild and the runtime (mxcli fetches the version your app needs).
  • hub.mxcli.org — the browser-preview tunnel (only if you’re using step 2).

A policy that permits general outbound HTTPS covers all of these. If your organization restricts egress, allow-list those hosts. See the network policy docs for where to configure this.

Even faster startup: if you control the environment image, pre-installing mxcli (and pre-caching MxBuild + the runtime) makes step 4 nearly instant and removes the one network-dependent step. This is the most robust setup for a team.

4. Start the session with the bootstrap prompt

Open a session on the environment and paste the bootstrap prompt. It tells the agent to set up the complete toolchain, create the app, wire the AI tooling and Dev Container, start a findings document, boot the app, and commit everything so the next session self-bootstraps.

👉 Copy the bootstrap prompt — it’s a single paste. In short, the agent will:

  • ensure mxcli is available (pre-installed, or download the nightly binary);
  • mxcli init --sync-skills — unpack the skills embedded in the binary, then follow the bootstrap-app skill, which carries the rest of this list (the paste itself is only those two steps plus “read the skill”);
  • interview you about the app, then mxcli new App --version <X.Y.Z> (or mxcli init if an .mpr already exists);
  • mxcli init --tool claude — adds a SessionStart hook so future sessions come back up automatically;
  • mxcli run --local --setup --ensure-db — cache MxBuild + runtime, start Postgres, create the app database;
  • create a FINDINGS.md (see below) and start logging to it;
  • commit App.mpr, .devcontainer/, and .claude/ so the steady state is file-driven;
  • boot mxcli run --local and verify the app answers HTTP 200.

You can append your own goal to the end of the prompt — e.g. “Then seed a domain model, pages, and microflows for a time-registration app”, or link a design prototype to seed from.

Keep a findings document

The bootstrap prompt asks the agent to create a FINDINGS.md at the repo root and to append to it as it works. Treat it as a lab notebook for the session:

  • anything surprising or broken — an mxcli command that errored, a workaround you had to apply, a check that passed but a real mx check later flagged;
  • the Mendix / mxcli versions in play and how each finding was verified;
  • decisions and dead-ends, so a later session (or a teammate) doesn’t repeat them.

This is high-leverage for two reasons. It gives your next session durable context that survives container reaping, and — because mxcli is fast-moving alpha — a clear findings doc is the single most useful thing you can share back to improve mxcli. If something misbehaves, capture the exact MDL, the command, and the error in FINDINGS.md; that’s often enough for a fix.

5. Iterate

Once the app is up, use the warm local dev loop — a Docker-free, ~1-second edit→test cycle:

mxcli run --local -p App.mpr --watch --screenshot   # hot-reload + a PNG per change

Edit the model with MDL and the running loop hot-applies it:

mxcli exec change.mdl -p App.mpr                     # the loop picks it up and reloads

To see the app in a browser from the web session, add --hub (this is where the key from step 2 pays off):

mxcli run --hub https://hub.mxcli.org -p App.mpr     # prints a shareable preview URL

--hub implies --local, so you get the warm loop and a public preview URL in one command — edit here, hot-apply, refresh the tab.

--hub ships in the Linux build only, and a Claude Code web session is a Linux container, so it works here. It is the native Windows/macOS installs that leave the tunnel out — see Linux builds only.

See mxcli run –local for --watch, --ensure-db, --setup, the screenshot flags, and the full --hub reference.

After idle: sessions self-bootstrap

Cloud containers are reaped when idle, so a resumed session starts from the committed files, not from re-pasting the prompt. Because step 4 ran mxcli init, .claude/settings.json carries a SessionStart hook that runs mxcli run --local --setup --ensure-db on every new session — it re-caches MxBuild + runtime and re-provisions the database, leaving the session ready to run --local. You never re-paste the bootstrap prompt; it’s a one-time seed.

Next steps

  • The bootstrap prompt — the exact copy-paste text and the rules that make it robust.
  • Local Dev Loop — the warm loop, browser preview, and screenshots in depth.
  • Claude Code Integration — how skills, CLAUDE.md, and slash commands shape what the agent does with your project (applies to web and local).

Exploring a Project

Once you have a project open – whether through the REPL, a CLI one-liner, or a script file – the next step is to look around. What modules exist? What entities are defined? What does a particular microflow do?

mxcli provides three families of commands for exploration:

  • SHOW commands list elements by type. LIST ENTITIES lists all entities; LIST MICROFLOWS IN Sales narrows the list to one module.
  • DESCRIBE commands display the full MDL source for a single element, giving you the complete definition including attributes, associations, logic, and widget trees.
  • SEARCH performs full-text search across every string in the project – captions, messages, expressions, documentation, and more.
  • DESCRIBE STRUCTURE gives you a compact tree view of the entire project or a single module, at varying levels of detail.

These commands are read-only. They never modify your project. You can run them freely to build a mental model of the application before making any changes.

What you will learn

In this chapter, you will:

  1. List modules, entities, microflows, pages, and other elements with SHOW commands
  2. Inspect the full definition of any element with DESCRIBE
  3. Find elements by keyword with SEARCH
  4. Get a bird’s-eye view of project structure with DESCRIBE STRUCTURE

Prerequisites

You should have mxcli installed and know how to open a project. If not, work through the Setting Up chapter first.

The examples in this chapter assume you have a Mendix project open. You can follow along using either the REPL or CLI one-liners:

# REPL (interactive)
mxcli -p /path/to/app.mpr

# CLI one-liner
mxcli -p /path/to/app.mpr -c "LIST ENTITIES"

If you do not have a Mendix project handy, the commands will still make sense – the output format and options are the same regardless of the project content.

LIST MODULES, LIST ENTITIES

The SHOW family of commands lists project elements by type. They are the fastest way to see what a project contains.

Listing modules

Every Mendix project is organized into modules. Start by listing them:

LIST MODULES;

Example output:

MyFirstModule
Administration
Atlas_Core
System

By default, system and marketplace modules (like System and Atlas_Core) are included. The modules appear in the order they are defined in the project.

Listing entities

To see all entities across all modules:

LIST ENTITIES;

Example output:

Administration.Account
MyFirstModule.Customer
MyFirstModule.Order
MyFirstModule.OrderLine

Each entity is shown as a qualified name – the module name and entity name separated by a dot.

Filtering by module

Most SHOW commands accept an IN clause to filter results to a single module:

LIST ENTITIES IN MyFirstModule;
MyFirstModule.Customer
MyFirstModule.Order
MyFirstModule.OrderLine

This is typically what you want when working on a specific module.

Listing microflows

LIST MICROFLOWS;
Administration.ChangeMyPassword
MyFirstModule.ACT_Customer_Save
MyFirstModule.ACT_Order_Process
MyFirstModule.DS_Customer_GetAll

Filter to a module:

LIST MICROFLOWS IN MyFirstModule;
MyFirstModule.ACT_Customer_Save
MyFirstModule.ACT_Order_Process
MyFirstModule.DS_Customer_GetAll

Listing pages

LIST PAGES;
Administration.Account_Overview
Administration.Login
MyFirstModule.Customer_Overview
MyFirstModule.Customer_Edit
MyFirstModule.Order_Detail

Filter to a module:

LIST PAGES IN MyFirstModule;
MyFirstModule.Customer_Overview
MyFirstModule.Customer_Edit
MyFirstModule.Order_Detail

Other SHOW commands

The same pattern works for all major element types:

LIST ENUMERATIONS;
LIST ENUMERATIONS IN MyFirstModule;

LIST ASSOCIATIONS;
LIST ASSOCIATIONS IN MyFirstModule;

LIST WORKFLOWS;
LIST WORKFLOWS IN MyFirstModule;

LIST NANOFLOWS;
LIST NANOFLOWS IN MyFirstModule;

LIST CONSTANTS;
LIST CONSTANTS IN MyFirstModule;

LIST SNIPPETS;
LIST SNIPPETS IN MyFirstModule;

You can also list security-related elements:

LIST MODULE ROLES;
LIST MODULE ROLES IN MyFirstModule;

LIST USER ROLES;

LIST DEMO USERS;

And navigation:

LIST NAVIGATION;

Using SHOW from the command line

Every SHOW command works as a CLI one-liner with -c:

mxcli -p app.mpr -c "LIST ENTITIES"
mxcli -p app.mpr -c "LIST MICROFLOWS IN MyFirstModule"
mxcli -p app.mpr -c "LIST PAGES"

This is useful for quick lookups without entering the REPL, and for piping output to other tools:

# Count entities per module
mxcli -p app.mpr -c "LIST ENTITIES" | cut -d. -f1 | sort | uniq -c

# Find all microflows with "Save" in the name
mxcli -p app.mpr -c "LIST MICROFLOWS" | grep -i save

Summary of SHOW commands

CommandDescription
LIST MODULESList all modules
LIST ENTITIES [IN Module]List entities
LIST MICROFLOWS [IN Module]List microflows
LIST NANOFLOWS [IN Module]List nanoflows
LIST PAGES [IN Module]List pages
LIST SNIPPETS [IN Module]List snippets
LIST ENUMERATIONS [IN Module]List enumerations
LIST ASSOCIATIONS [IN Module]List associations
LIST CONSTANTS [IN Module]List constants
LIST WORKFLOWS [IN Module]List workflows
LIST BUSINESS EVENTS [IN Module]List business event services
LIST JAVA ACTIONS [IN Module]List Java actions
LIST MODULE ROLES [IN Module]List module roles
LIST USER ROLESList user roles
LIST DEMO USERSList demo users
LIST NAVIGATIONShow navigation profiles

Now that you can list elements, the next step is inspecting individual elements in detail with DESCRIBE and SEARCH.

DESCRIBE, SEARCH

While SHOW commands list elements by name, DESCRIBE gives you the full definition of a single element. SEARCH lets you find elements by keyword when you do not know the exact name.

DESCRIBE ENTITY

To see the complete definition of an entity, including its attributes, types, and constraints:

DESCRIBE ENTITY MyFirstModule.Customer;

Example output:

CREATE PERSISTENT ENTITY MyFirstModule.Customer (
  Name: String(200),
  Email: String(200),
  Phone: String(50),
  DateOfBirth: DateTime,
  IsActive: Boolean DEFAULT false
);

The output is valid MDL – you could copy it, modify it, and execute it as a CREATE OR MODIFY statement. This round-trip capability is one of the key design features of MDL.

DESCRIBE also shows associations and access rules when they exist:

DESCRIBE ENTITY MyFirstModule.Order;
mdl 1;
CREATE PERSISTENT ENTITY MyFirstModule.Order (
  OrderNumber: AutoNumber DEFAULT 1,
  OrderDate: DateTime,
  TotalAmount: Decimal,
  Status: MyFirstModule.OrderStatus
);

CREATE ASSOCIATION MyFirstModule.Order_Customer
  FROM MyFirstModule.Order TO MyFirstModule.Customer;

DESCRIBE MICROFLOW

To see the full logic of a microflow:

DESCRIBE MICROFLOW MyFirstModule.ACT_Customer_Save;

Example output:

CREATE MICROFLOW MyFirstModule.ACT_Customer_Save
  ($Customer: MyFirstModule.Customer)
  RETURNS Boolean
BEGIN
  IF $Customer/Name = empty THEN
    VALIDATION FEEDBACK $Customer/Name MESSAGE 'Name is required';
    RETURN false;
  END IF;

  COMMIT $Customer;
  RETURN true;
END;

This shows the parameters, return type, and the complete flow logic. For complex microflows, this can be quite long – but it gives you the full picture in one command.

DESCRIBE PAGE

Pages are shown as their widget tree:

DESCRIBE PAGE MyFirstModule.Customer_Edit;

Example output:

CREATE PAGE MyFirstModule.Customer_Edit
(
  Params: ( $Customer: MyFirstModule.Customer ),
  Title: 'Edit Customer',
  Layout: Atlas_Core.PopupLayout
)
{
  DATAVIEW dvCustomer (DataSource: $Customer) {
    TEXTBOX txtName (Label: 'Name', Attribute: Name)
    TEXTBOX txtEmail (Label: 'Email', Attribute: Email)
    TEXTBOX txtPhone (Label: 'Phone', Attribute: Phone)

    FOOTER {
      ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)
      ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
    }
  }
};

DESCRIBE ENUMERATION

DESCRIBE ENUMERATION MyFirstModule.OrderStatus;
CREATE ENUMERATION MyFirstModule.OrderStatus (
  Draft 'Draft',
  Submitted 'Submitted',
  Approved 'Approved',
  Rejected 'Rejected',
  Completed 'Completed'
);

Each value is followed by its caption (the display label shown to end users).

DESCRIBE ASSOCIATION

DESCRIBE ASSOCIATION MyFirstModule.Order_Customer;

DESCRIBE prints only the clauses that differ from the defaults (TYPE Reference, OWNER Default, STORAGE COLUMN, ON DELETE SET NULL), so an association that uses all of them comes back as just its ends:

CREATE ASSOCIATION MyFirstModule.Order_Customer
  FROM MyFirstModule.Order TO MyFirstModule.Customer;

DESCRIBE MODULE

To see the full contents of a module (all entities, microflows, pages, etc.):

DESCRIBE MODULE MyFirstModule;

This is especially useful from the CLI:

mxcli describe -p app.mpr module MyFirstModule

Other DESCRIBE targets

The same pattern works for other element types:

DESCRIBE MODULE MyFirstModule;
DESCRIBE WORKFLOW MyFirstModule.ApprovalFlow;
DESCRIBE NANOFLOW MyFirstModule.NAV_GoToDetail;
DESCRIBE SNIPPET MyFirstModule.CustomerCard;
DESCRIBE CONSTANT MyFirstModule.ApiBaseUrl;
DESCRIBE JSON STRUCTURE MyFirstModule.CustomerResponse;
DESCRIBE IMPORT MAPPING MyFirstModule.IMM_Customer;
DESCRIBE EXPORT MAPPING MyFirstModule.EMM_Customer;
DESCRIBE CONSUMED REST SERVICE MyFirstModule.PetStoreAPI;
DESCRIBE IMAGE COLLECTION MyFirstModule.Icons;
DESCRIBE NAVIGATION;
DESCRIBE SETTINGS;

When you do not know the exact name of an element, use SEARCH to find it by keyword. This searches across all strings in the project – entity names, attribute names, microflow logic, page captions, messages, documentation, and more.

SEARCH 'validation';

Example output:

MyFirstModule.ACT_Customer_Save (Microflow)
  "VALIDATION FEEDBACK $Customer/Name MESSAGE 'Name is required'"

MyFirstModule.ACT_Order_Validate (Microflow)
  "VALIDATION FEEDBACK $Order/OrderDate MESSAGE 'Order date cannot be in the past'"

MyFirstModule.Customer_Edit (Page)
  "Validation errors will appear here"

Search is case-insensitive and matches partial words.

Search from the command line

The CLI provides a dedicated search subcommand with formatting options:

# Search with default output
mxcli search -p app.mpr "validation"

# Show only element names (no context)
mxcli search -p app.mpr "validation" --format names

# JSON output for programmatic use (stdout is only the JSON array;
# progress goes to stderr, and no matches is [])
mxcli search -p app.mpr "validation" --json

The --format names option is useful for piping into other commands:

# Find all microflows mentioning "email" and describe each one
mxcli search -p app.mpr "email" --format names | while read name; do
  mxcli -p app.mpr -c "DESCRIBE MICROFLOW $name"
done

A typical exploration workflow looks like this:

  1. Start broad with SHOW to see what exists:

    LIST MODULES;
    LIST ENTITIES IN Sales;
    
  2. Zoom in with DESCRIBE on interesting elements:

    DESCRIBE ENTITY Sales.Order;
    DESCRIBE MICROFLOW Sales.ACT_Order_Process;
    
  3. Search when you need to find something specific:

    SEARCH 'discount';
    

This workflow mirrors how you would explore a project in Mendix Studio Pro – browsing the project explorer, opening documents, and using Find to locate things.

Next, learn how to get a compact overview of the entire project with DESCRIBE STRUCTURE.

DESCRIBE STRUCTURE

The DESCRIBE STRUCTURE command gives you a compact, tree-style overview of a project. It is the fastest way to understand the overall shape of an application – what modules exist, what types of documents they contain, and how large each module is.

Default view

With no arguments, DESCRIBE STRUCTURE displays all user modules at depth 2 – modules with their documents listed by type:

DESCRIBE STRUCTURE;

Example output:

MyFirstModule
  Entities
    Customer (Name, Email, Phone, DateOfBirth, IsActive)
    Order (OrderNumber, OrderDate, TotalAmount, Status)
    OrderLine (Quantity, UnitPrice, LineTotal)
  Microflows
    ACT_Customer_Save ($Customer: Customer) : Boolean
    ACT_Order_Process ($Order: Order) : Boolean
    DS_Customer_GetAll () : List of Customer
  Pages
    Customer_Overview
    Customer_Edit ($Customer: Customer)
    Order_Detail ($Order: Order)
  Enumerations
    OrderStatus (Draft, Submitted, Approved, Rejected, Completed)
Administration
  Entities
    Account (FullName, Email, IsLocalUser, BlockedSince)
  Microflows
    ChangeMyPassword ($OldPassword, $NewPassword) : Boolean
  Pages
    Account_Overview
    Login

This gives you a bird’s-eye view without opening individual elements. Entity signatures show attribute names; microflow signatures show parameters and return types.

Depth levels

The DEPTH option controls how much detail is shown:

DEPTH 1 – Module summary

Shows one line per module with element counts:

DESCRIBE STRUCTURE DEPTH 1;
MyFirstModule       3 entities, 3 microflows, 3 pages, 1 enumeration, 2 associations
Administration      1 entity, 1 microflow, 2 pages

This is useful for getting a quick sense of project size and where the complexity lives.

DEPTH 2 – Elements with signatures (default)

This is the default when you run DESCRIBE STRUCTURE with no depth specified. It shows modules, their documents grouped by type, and compact signatures for each element. See the example under Default view above.

DEPTH 3 – Full detail

Shows typed attributes and named parameters:

DESCRIBE STRUCTURE DEPTH 3;
MyFirstModule
  Entities
    Customer
      Name: String(200)
      Email: String(200)
      Phone: String(50)
      DateOfBirth: DateTime
      IsActive: Boolean DEFAULT false
    Order
      OrderNumber: AutoNumber
      OrderDate: DateTime
      TotalAmount: Decimal
      Status: MyFirstModule.OrderStatus
    OrderLine
      Quantity: Integer
      UnitPrice: Decimal
      LineTotal: Decimal
  Microflows
    ACT_Customer_Save ($Customer: MyFirstModule.Customer) : Boolean
    ACT_Order_Process ($Order: MyFirstModule.Order) : Boolean
    DS_Customer_GetAll () : List of MyFirstModule.Customer
  Pages
    Customer_Overview
    Customer_Edit ($Customer: MyFirstModule.Customer)
    Order_Detail ($Order: MyFirstModule.Order)
  Enumerations
    OrderStatus
      Draft 'Draft'
      Submitted 'Submitted'
      Approved 'Approved'
      Rejected 'Rejected'
      Completed 'Completed'
  Associations
    Order_Customer: Order -> Customer (Reference)
    OrderLine_Order: OrderLine -> Order (Reference)

Depth 3 is verbose but gives you the most complete picture without running individual DESCRIBE commands.

At depths 2 and 3 each page also lists its outermost data widgets, the ones that set up a data context, for example Page MyFirstModule.Customer_Edit [DataView<Customer>]. A data widget nested inside another one is not listed. The widget index is built only by REFRESH CATALOG FULL, so on a default catalog pages print without the list.

Filtering by module

Use IN to show only a single module:

DESCRIBE STRUCTURE IN MyFirstModule;

This produces the same tree format but limited to one module. Combine with DEPTH for control over detail:

DESCRIBE STRUCTURE DEPTH 3 IN MyFirstModule;

Including system modules

By default, system and marketplace modules are hidden. Add ALL to include them:

DESCRIBE STRUCTURE DEPTH 1 ALL;
MyFirstModule       3 entities, 3 microflows, 3 pages, 1 enumeration, 2 associations
Administration      1 entity, 1 microflow, 2 pages
Atlas_Core          0 entities, 0 microflows, 12 pages
System              15 entities, 0 microflows, 0 pages

This is useful when you need to see system entities (like System.Image or System.FileDocument) or check what a marketplace module provides.

Using DESCRIBE STRUCTURE from the command line

# Quick project overview
mxcli -p app.mpr -c "DESCRIBE STRUCTURE DEPTH 1"

# Detailed view of one module
mxcli -p app.mpr -c "DESCRIBE STRUCTURE DEPTH 3 IN Sales"

# Full project including system modules
mxcli -p app.mpr -c "DESCRIBE STRUCTURE ALL"

When to use DESCRIBE STRUCTURE vs SHOW + DESCRIBE

GoalCommand
“What modules are in this project?”DESCRIBE STRUCTURE DEPTH 1
“What does module X contain?”DESCRIBE STRUCTURE IN X
“List all entities (just names)”LIST ENTITIES
“What are the attributes of entity X?”DESCRIBE ENTITY X
“Give me a complete overview of everything”DESCRIBE STRUCTURE DEPTH 3

DESCRIBE STRUCTURE is best for orientation – understanding the shape of the project at a glance. For detailed work on specific elements, switch to DESCRIBE.

What is next

Now that you can explore a project, you are ready to start making changes. Continue to Your First Changes to learn how to create entities, microflows, and pages using MDL.

Your First Changes

Now that you can explore a project with SHOW and DESCRIBE, it’s time to make changes. This chapter walks through the three most common modifications: creating an entity, a microflow, and a page.

Before you start

Always work on a copy. mxcli writes directly to your .mpr file. Before making changes, either:

  • Copy the .mpr file (and mprcontents/ folder if it exists) to a scratch directory
  • Use Git so you can revert with git checkout
# Make a working copy
cp app.mpr app-scratch.mpr
cp -r mprcontents/ mprcontents-scratch/   # only for MPR v2 projects

The workflow

Every modification follows the same pattern:

  1. Write MDL – either directly on the command line with -c, or in a .mdl script file
  2. Check syntax – run mxcli check to catch errors before touching the project
  3. Execute – apply the changes to the .mpr file
  4. Validate – use mxcli docker check for a full Studio Pro-level validation
  5. Open in Studio Pro – confirm the result visually
# Step 1-2: Write and check syntax
mxcli check setup.mdl

# Step 3: Execute against the project
mxcli exec setup.mdl -p app.mpr

# Step 4: Full validation
mxcli docker check -p app.mpr

# Step 5: Open in Studio Pro and inspect

You can also skip the script file and execute commands directly:

mxcli -p app.mpr -c "CREATE PERSISTENT ENTITY MyModule.Product (Name: String(200) NOT NULL, Price: Decimal);"

What we’ll build

Over the next few pages, you’ll create:

WhatMDL statementPage
A Product entity with attributesCREATE PERSISTENT ENTITYCreating an Entity
A microflow that creates productsCREATE MICROFLOWCreating a Microflow
An overview page listing productsCREATE PAGECreating a Page

The final page, Validating with mxcli check, covers the full validation workflow in detail.

Quick note on module names

Every MDL statement references a module. In these examples we use MyModule – replace it with whatever module exists in your project. You can check available modules with:

mxcli -p app.mpr -c "LIST MODULES"

Idempotent scripts with OR MODIFY

If you plan to run a script more than once (common during development), use CREATE OR MODIFY instead of plain CREATE. This updates the entity if it already exists instead of failing:

CREATE OR MODIFY PERSISTENT ENTITY MyModule.Product (
    Name: String(200) NOT NULL,
    Price: Decimal,
    IsActive: Boolean DEFAULT true
);

The OR MODIFY variant is available for entities, enumerations, microflows, and pages. You’ll see it used throughout the tutorial.

Creating an Entity

Entities are the foundation of any Mendix application – they define your data model. In this page you’ll create a Product entity with several attributes, verify it, and then link it to another entity with an association.

Create a simple entity

The CREATE PERSISTENT ENTITY statement defines an entity that is stored in the database. Attributes go inside parentheses, separated by commas:

CREATE PERSISTENT ENTITY MyModule.Product (
    Name: String(200) NOT NULL,
    Price: Decimal,
    IsActive: Boolean DEFAULT true
);

Let’s break this down:

PartMeaning
PERSISTENTThe entity is stored in the database (as opposed to NON-PERSISTENT, which exists only in memory)
MyModule.ProductFully qualified name: module dot entity name
String(200)A string attribute with a maximum length of 200 characters
NOT NULLThe attribute is required – it cannot be left empty
DecimalA decimal number (no precision/scale needed for basic use)
Boolean DEFAULT trueA boolean that defaults to true when a new object is created

Run it from the command line:

mxcli -p app.mpr -c "CREATE PERSISTENT ENTITY MyModule.Product (Name: String(200) NOT NULL, Price: Decimal, IsActive: Boolean DEFAULT true);"

Or save it to a file and execute:

mxcli exec create-product.mdl -p app.mpr

Verify the entity

Use DESCRIBE ENTITY to confirm your entity was created correctly:

mxcli -p app.mpr -c "DESCRIBE ENTITY MyModule.Product"

This prints the full MDL definition of the entity, including all attributes and their types. You should see Name, Price, and IsActive listed.

Add an association

Associations link entities together. To connect Product to an existing Order entity, create a reference association:

CREATE ASSOCIATION MyModule.Order_Product
    FROM MyModule.Order TO MyModule.Product
    TYPE Reference;

This creates a many-to-one relationship: each Order can reference one Product. Use ReferenceSet instead of Reference for many-to-many.

The naming convention Order_Product follows Mendix best practices – the “from” entity name comes first, then the “to” entity.

Association types

TypeMeaningExample
ReferenceMany-to-one (or one-to-one)An Order references one Product
ReferenceSetMany-to-manyAn Order can have multiple Products

Delete behavior

You can specify what happens when the “to” entity is deleted:

CREATE ASSOCIATION MyModule.Order_Product
    FROM MyModule.Order TO MyModule.Product
    TYPE Reference
    ON DELETE RESTRICT;

Options: PREVENT (block deletion if referenced), CASCADE (delete the associated objects too), or leave it out for the default behavior — delete the object and null out the references.

Using OR MODIFY for idempotent scripts

If you want to run the same script repeatedly without errors, use CREATE OR MODIFY:

CREATE OR MODIFY PERSISTENT ENTITY MyModule.Product (
    Name: String(200) NOT NULL,
    Price: Decimal,
    IsActive: Boolean DEFAULT true,
    Description: String(unlimited)
);

If the entity already exists, this updates it to match the new definition. If it doesn’t exist, it creates it. This is especially useful during iterative development.

More attribute types

Here’s a fuller example showing the attribute types you’ll use most often:

CREATE PERSISTENT ENTITY MyModule.Product (
    Name: String(200) NOT NULL,
    Description: String(unlimited),
    Price: Decimal,
    Quantity: Integer,
    Weight: Long,
    IsActive: Boolean DEFAULT true,
    ReleaseDate: DateTime,
    Status: MyModule.ProductStatus
);

The last attribute references an enumeration (MyModule.ProductStatus). You’d create that separately:

CREATE ENUMERATION MyModule.ProductStatus (
    Active 'Active',
    Discontinued 'Discontinued',
    OutOfStock 'Out of Stock'
);

Extending a system entity

To create an entity that inherits from a system entity (like System.Image for file storage), use EXTENDS. Note that EXTENDS must come before the opening parenthesis:

-- Correct: EXTENDS before (
CREATE PERSISTENT ENTITY MyModule.ProductPhoto EXTENDS System.Image (
    PhotoCaption: String(200)
);
-- Wrong: EXTENDS after ( -- this will cause a parse error
CREATE PERSISTENT ENTITY MyModule.ProductPhoto (
    PhotoCaption: String(200)
) EXTENDS System.Image;

Common mistakes

String needs an explicit length. String alone is not valid – you must specify the maximum length:

-- Wrong
Name: String

-- Correct
Name: String(200)

-- For unlimited length
Description: String(unlimited)

EXTENDS must come before the parenthesis. See the example above. This is a common source of parse errors.

Module must exist. The module in the qualified name (e.g., MyModule in MyModule.Product) must already exist in the project. Check with LIST MODULES.

Creating a Microflow

Microflows are the server-side logic of a Mendix application. They’re comparable to functions or methods in traditional programming. In this page you’ll create a microflow that accepts parameters, creates an object, commits it to the database, and returns it.

Create a simple microflow

This microflow takes a name and price, creates a Product object, commits it, and returns it:

CREATE MICROFLOW MyModule.CreateProduct(
    DECLARE $Name: String,
    DECLARE $Price: Decimal
)
RETURN MyModule.Product
BEGIN
    CREATE $Product: MyModule.Product (
        Name = $Name,
        Price = $Price,
        IsActive = true
    );
    COMMIT $Product;
    RETURN $Product;
END;

Let’s walk through each part:

PartMeaning
MyModule.CreateProductFully qualified microflow name
DECLARE $Name: StringInput parameter – a string value passed by the caller
RETURN MyModule.ProductThe microflow returns a Product entity object
BEGIN ... ENDThe microflow body
CREATE $Product: MyModule.Product (...)Creates a new Product object in memory and sets its attributes
COMMIT $ProductPersists the object to the database
RETURN $ProductReturns the committed object to the caller

Save this to a file (e.g., create-product-mf.mdl) and execute:

mxcli check create-product-mf.mdl
mxcli exec create-product-mf.mdl -p app.mpr

Verify the microflow

Use DESCRIBE MICROFLOW to see the generated MDL:

mxcli -p app.mpr -c "DESCRIBE MICROFLOW MyModule.CreateProduct"

This shows the full microflow definition, including parameters, activities, and the return type.

Understanding variables

All variables in MDL start with $. There are two contexts where you declare them:

Parameters (in the signature):

CREATE MICROFLOW MyModule.DoSomething(
    DECLARE $Customer: MyModule.Customer,
    DECLARE $Count: Integer
)

Local variables (inside BEGIN…END):

BEGIN
    DECLARE $Total: Integer = 0;
    DECLARE $Message: String = 'Processing...';
    DECLARE $Items: List of MyModule.Item = empty;
END;

For entity parameters, do not assign a default value – just declare the type:

-- Correct: entity parameter with no default
DECLARE $Customer: MyModule.Customer

-- Wrong: entity parameter with = empty
DECLARE $Customer: MyModule.Customer = empty

Retrieving objects

Use RETRIEVE to fetch objects from the database:

CREATE MICROFLOW MyModule.GetActiveProducts()
RETURN List of MyModule.Product
BEGIN
    RETRIEVE $Products: List of MyModule.Product
        FROM MyModule.Product
        WHERE IsActive = true;
    RETURN $Products;
END;

To retrieve a single object, add LIMIT 1:

RETRIEVE $Product: MyModule.Product
    FROM MyModule.Product
    WHERE Name = $SearchName
    LIMIT 1;

Conditional logic

Use IF ... THEN ... ELSE ... END IF for branching:

CREATE MICROFLOW MyModule.UpdateProductStatus(
    DECLARE $Product: MyModule.Product
)
RETURN Boolean
BEGIN
    IF $Product/Price > 0 THEN
        CHANGE $Product (IsActive = true);
        COMMIT $Product;
        RETURN true;
    ELSE
        LOG WARNING 'Product has no price set';
        RETURN false;
    END IF;
END;

Looping over a list

Use LOOP ... IN ... BEGIN ... END LOOP to iterate:

CREATE MICROFLOW MyModule.DeactivateProducts(
    DECLARE $Products: List of MyModule.Product
)
RETURN Boolean
BEGIN
    LOOP $Product IN $Products
    BEGIN
        CHANGE $Product (IsActive = false);
        COMMIT $Product;
    END LOOP;
    RETURN true;
END;

Note: the list $Products is a parameter. Never create an empty list variable and then loop over it – accept the list as a parameter instead.

Error handling

Add ON ERROR to handle failures on individual activities:

COMMIT $Product ON ERROR CONTINUE;

Options are CONTINUE (ignore the error and proceed), ROLLBACK (roll back the transaction and continue), or an inline error handler block:

COMMIT $Product ON ERROR BEGIN
    LOG ERROR 'Failed to commit product';
    RETURN false;
END ERROR;

Organizing with folders

Place microflows in folders using the FOLDER keyword:

CREATE MICROFLOW MyModule.CreateProduct(
    DECLARE $Name: String,
    DECLARE $Price: Decimal
)
RETURN MyModule.Product
FOLDER 'ACT'
BEGIN
    CREATE $Product: MyModule.Product (
        Name = $Name,
        Price = $Price,
        IsActive = true
    );
    COMMIT $Product;
    RETURN $Product;
END;

This places the microflow in the ACT folder within the module, following the common Mendix convention of grouping microflows by type (ACT for actions, VAL for validations, SUB for sub-microflows).

Common mistakes

Every flow path must end with RETURN. If your microflow has IF/ELSE branches, each branch needs its own RETURN statement (or the return can come after END IF if both branches converge).

COMMIT is required to persist changes. CREATE and CHANGE only modify the object in memory. Without COMMIT, changes are lost when the microflow ends.

Entity parameters don’t use = empty. Declare them with just the type. Assigning = empty is for list variables inside the microflow body, not for parameters.

Creating a Page

Pages are the user interface of a Mendix application. In this page you’ll create an overview page that lists products in a data grid, and then an edit page with a form for modifying a single product.

Prerequisites

Before creating a page, you need two things:

  1. An entity – the page needs data to display. We’ll use the MyModule.Product entity from the previous steps.
  2. A layout – every page is placed inside a layout that provides the overall page structure (header, sidebar, content area). The layout must already exist in your project.

To see what layouts are available:

mxcli -p app.mpr -c "LIST PAGES IN Atlas_Core"

Most Mendix projects based on Atlas UI have layouts like Atlas_Core.Atlas_Default (full page), Atlas_Core.PopupLayout (dialog), and others.

Create an overview page

An overview page typically shows a data grid that lists all objects of an entity:

CREATE PAGE MyModule.ProductOverview
(
    Title: 'Products',
    Layout: Atlas_Core.Atlas_Default
)
{
    DATAGRID dgProducts (DataSource: DATABASE MyModule.Product) {
        COLUMN (Attribute: Name, Caption: 'Name')
        COLUMN (Attribute: Price, Caption: 'Price')
        COLUMN (Attribute: IsActive, Caption: 'Active')
    }
};

Let’s break this down:

PartMeaning
MyModule.ProductOverviewFully qualified page name
LAYOUT Atlas_Core.Atlas_DefaultThe layout this page uses – must exist in the project
TITLE 'Products'The page title shown in the browser tab and header
DATAGRID SOURCE DATABASE MyModule.ProductA data grid that loads Product objects from the database
COLUMN NameA column showing the Name attribute

Execute it:

mxcli -p app.mpr -c "CREATE PAGE MyModule.ProductOverview (Title: 'Products', Layout: Atlas_Core.Atlas_Default) { DATAGRID dgProducts (DataSource: DATABASE MyModule.Product) { COLUMN colName (Attribute: Name, Caption: 'Name') COLUMN colPrice (Attribute: Price, Caption: 'Price') COLUMN colActive (Attribute: IsActive, Caption: 'Active') } }"

Or save to a file and run:

mxcli check create-pages.mdl
mxcli exec create-pages.mdl -p app.mpr

Verify the page

mxcli -p app.mpr -c "DESCRIBE PAGE MyModule.ProductOverview"

This prints the full page definition with all widgets and their properties.

Create an edit page

Edit pages use a DataView to display and edit a single object. The object is passed as a page parameter:

CREATE PAGE MyModule.Product_Edit
(
    Params: ( $Product: MyModule.Product ),
    Title: 'Edit Product',
    Layout: Atlas_Core.PopupLayout
)
{
    DATAVIEW dvProduct (DataSource: $Product) {
        TEXTBOX txtName (Label: 'Name', Attribute: Name)
        TEXTBOX txtPrice (Label: 'Price', Attribute: Price)
        CHECKBOX cbActive (Label: 'Active', Attribute: IsActive)

        FOOTER {
            ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)
            ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
        }
    }
};

Key differences from the overview page:

PartMeaning
Params: ( $Product: MyModule.Product )The page expects a Product object to be passed when opened
Layout: Atlas_Core.PopupLayoutUses a popup/dialog layout instead of a full page
DATAVIEW dvProduct (DataSource: $Product)Binds to the page parameter
TEXTBOX, CHECKBOXInput widgets bound to entity attributes
FOOTERA section at the bottom of the DataView for action buttons
Action: SAVE CHANGESBuilt-in action that commits the object and closes the page
Action: CANCEL CHANGESBuilt-in action that rolls back changes and closes the page

Notice the two different page syntaxes: the overview page uses the compact syntax (LAYOUT and TITLE as keywords before parentheses), while the edit page uses the property syntax (properties inside a (Key: value) block followed by a { widget tree } block). Both are valid – use whichever fits better.

Widget reference

Here are the most commonly used widgets:

Input widgets

TEXTBOX txtName (Label: 'Name', Attribute: Name)
TEXTAREA txtDesc (Label: 'Description', Attribute: Description)
CHECKBOX cbActive (Label: 'Active', Attribute: IsActive)
DATEPICKER dpCreated (Label: 'Created', Attribute: CreatedDate)
COMBOBOX cbStatus (Label: 'Status', Attribute: Status)
RADIOBUTTONS rbType (Label: 'Type', Attribute: ProductType)

Display widgets

DYNAMICTEXT dynName (Attribute: Name)

Action buttons

ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)
ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
ACTIONBUTTON btnDelete (Caption: 'Delete', Action: DELETE, ButtonStyle: Danger)
ACTIONBUTTON btnProcess (Caption: 'Process', Action: CALL MICROFLOW MyModule.ACT_ProcessProduct(Product = $Product))

Layout widgets

CONTAINER cntWrapper (Class: 'card') {
    -- child widgets go here
}

LAYOUTGRID lgMain {
    ROW r1 {
        COLUMN c1 (Weight: 6) { ... }
        COLUMN c2 (Weight: 6) { ... }
    }
}

Embedding a snippet

To reuse a page fragment, call a snippet:

SNIPPETCALL scProductCard (Snippet: MyModule.ProductCard)

The snippet must already exist in the project.

Common mistakes

Layout must exist in the project. If you specify a layout that doesn’t exist, the page will fail validation. Check available layouts with LIST PAGES and look for documents of type Layout.

Widget names must be unique within a page. Every widget needs a name (e.g., txtName, btnSave), and these must not collide within the same page.

DataView needs a data source. A DataView must know where its data comes from – either a page parameter (DataSource: $Product), a microflow, or a nested context.

Validating with mxcli check

You’ve created entities, microflows, and pages. Before opening your project in Studio Pro, you should validate that everything is correct. mxcli provides three levels of validation, each catching different kinds of problems.

Level 1: Syntax check (no project needed)

The fastest check. It parses your MDL script and reports syntax errors without touching any .mpr file:

mxcli check script.mdl --no-project

Without --no-project, a plain mxcli check script.mdl run from a directory holding exactly one .mpr auto-detects it (it prints Using project: ...) and goes on to Level 2 against it. --no-project gives the syntax-only check wherever you run it.

This catches:

  • Typos in keywords (CRETE instead of CREATE)
  • Missing semicolons or parentheses
  • Invalid attribute type syntax (String without a length)
  • Anti-patterns like empty list variables used as loop sources
  • Nested loops that should use RETRIEVE for lookups

You don’t even need a project file for this. It’s a pure syntax and structure check, so it runs instantly.

Checking inline commands

You can also check a single statement:

mxcli check -c "CREATE PERSISTENT ENTITY MyModule.Product (Name: String(200));"

Level 2: Syntax + reference validation

Add -p and --references to also verify that names in your script resolve to real elements in the project:

mxcli check script.mdl -p app.mpr --references

This catches everything Level 1 catches, plus:

  • References to entities that don’t exist (MyModule.NonExistent)
  • References to attributes that don’t exist on an entity
  • Microflow calls to non-existent microflows
  • Page layouts that don’t exist in the project
  • Association endpoints pointing to missing entities
  • A plain create whose name already exists in the project
  • A statement the project’s Mendix version cannot store (MDL-VERSION01, e.g. create view entity below 10.21) — the same version gates exec refuses with

This is the check you should run before executing a script. It’s fast (reads the project but doesn’t modify it) and catches most mistakes.

mxcli exec runs the same semantic checks before it writes anything, and refuses a script with an error. It prints errors and warnings in full but only counts the info notes (2 info notes not shown — run mxcli check script.mdl to see them), so a re-run stays readable; check prints every note, and exec --verbose does too.

References inside an excluded document (@excluded, or a page or snippet that stays excluded because its stored namesake is) are reported as Reference warnings rather than errors, because Mendix does not validate excluded documents. A missing data-source flow is a warning too, but only when every binding inside that container is qualified (Module.Entity.Attribute) — describe page writes them that way there. The widgets bind against the entity the flow returns, so with the flow missing a bare binding cannot be resolved and the check fails, naming the widget. A missing entity still fails the check.

Name conflicts with the project

A plain create of something the project already has is reported here rather than left to exec:

statement 4: association already exists in project: Sales.Order_Customer — use CREATE OR MODIFY to update it

This matters because exec stops at the first such statement, having already written the ones before it — so a script that only fails at execution time leaves the project half-modified. --references reports every conflict in the script up front, before anything is written.

Three spellings say “it is fine if this already exists”, and none of them is reported as a conflict:

FormWhat exec does
create or modify Xrewrites the element
create or replace Xrewrites the element
create X if not existsleaves the stored element untouched

create module M; is also never reported: it is a no-op when the module exists, which is why it can safely open every script.

Level 3: Full project validation with mx check

After executing your script, validate the entire project using the Mendix toolchain:

mxcli setup mxbuild -p app.mpr   # once per Mendix version
mxcli docker check -p app.mpr

This runs Mendix’s own mx check tool on a temporary copy of the project. It performs the same validation that Studio Pro does when you open a project, catching issues that mxcli’s parser cannot detect:

  • BSON serialization errors (malformed internal data)
  • Security configuration problems (missing access rules, CE0066 errors)
  • Widget definition mismatches (CE0463 errors), each labelled either a stale widget definition, which mxcli fix widgets clears, or one that persists after update-widgets
  • Missing required properties on widgets
  • Broken cross-references between documents

docker check does not download MxBuild, and does not use Docker: the docker in its name groups it with docker build. It runs the mx of an MxBuild already on the machine, as a local process, and stops with mx not found when there is none. mxcli setup mxbuild -p app.mpr downloads the project’s version once and caches it under ~/.mxcli/mxbuild/; mxcli new and mxcli docker build download it too.

With a local Mendix installation

If you have mx installed locally (e.g., from a Mendix installation), the check finds it on PATH or in the Studio Pro installation, and setup mxbuild is not needed. To choose a specific one:

mxcli docker check -p app.mpr --mxbuild-path /path/to/mendix

Here’s the workflow you should follow for every change:

Write MDL --> Check syntax --> Execute --> Docker check --> Open in Studio Pro

In practice:

# 1. Write your MDL in a script file
cat > changes.mdl << 'EOF'
CREATE OR MODIFY PERSISTENT ENTITY MyModule.Product (
    Name: String(200) NOT NULL,
    Price: Decimal,
    IsActive: Boolean DEFAULT true
);

CREATE OR MODIFY MICROFLOW MyModule.CreateProduct(
    DECLARE $Name: String,
    DECLARE $Price: Decimal
)
RETURN MyModule.Product
BEGIN
    CREATE $Product: MyModule.Product (
        Name = $Name,
        Price = $Price,
        IsActive = true
    );
    COMMIT $Product;
    RETURN $Product;
END;
EOF

# 2. Check syntax (fast, no project used)
mxcli check changes.mdl --no-project

# 3. Check references (needs project, still fast)
mxcli check changes.mdl -p app.mpr --references

# 4. Execute against the project
mxcli exec changes.mdl -p app.mpr

# 5. Full validation
mxcli docker check -p app.mpr

# 6. Open in Studio Pro and verify visually

Previewing changes with diff

Before executing, you can preview what a script would change:

mxcli diff -p app.mpr changes.mdl

This runs the script with exec’s own code on a scratch copy of the project and shows every document exec would create, modify or remove, each as its describe before and after. It does not modify the project.

What mxcli check catches automatically

Beyond basic syntax, mxcli check includes built-in anti-pattern detection:

PatternProblemWhat check reports
DECLARE $Items List of ... = empty followed by LOOP $Item IN $ItemsLooping over an empty list does nothingWarning: empty list variable used as loop source
Nested LOOP inside LOOP for list matchingO(N^2) performanceWarning: use RETRIEVE from list instead
Missing RETURN at end of flow pathMicroflow won’t compileError: missing return statement

Linting for deeper analysis

For a broader set of checks across the entire project (not just a single script), use the linter:

mxcli lint -p app.mpr

This runs 19 built-in rules plus 31 Starlark rules covering security, architecture, quality, and naming conventions. See mxcli lint --list-rules for the full list.

For CI/CD integration, output in SARIF format:

mxcli lint -p app.mpr --format sarif > results.sarif

Working with AI Assistants

mxcli is built from the ground up to work with AI coding assistants. The mxcli init command sets up your Mendix project with skills, configuration files, and a dev container so that AI tools can read and modify your project using MDL.

Why AI + MDL?

Mendix projects are stored in binary .mpr files. AI assistants cannot read or edit binary files directly. mxcli solves this by providing MDL – a text-based, SQL-like language that describes Mendix model elements. An AI assistant uses mxcli commands to explore the project, writes MDL scripts to make changes, validates them, and executes them against the .mpr file.

The result: you describe what you want in natural language, and the AI builds it in your Mendix project.

The token efficiency advantage

When working with AI APIs, context window size is a critical constraint. MDL’s compact syntax provides a significant advantage over JSON model representations:

RepresentationTokens for a 10-entity module
JSON (raw model)~15,000–25,000 tokens
MDL~2,000–4,000 tokens
Savings5–10x fewer tokens

Fewer tokens means lower API costs, more of your application fits in a single prompt, and the AI produces better results because there is less noise in the context.

Supported AI tools

mxcli supports six AI coding assistants out of the box, plus a universal format that works with any tool:

ToolInit FlagConfig FileDescription
Claude Code--tool claude (default).claude/, CLAUDE.mdFull integration with skills, commands, and lint rules
OpenCode--tool opencode.opencode/, opencode.jsonDeep integration with skills, commands, and lint rules
Cursor--tool cursor.cursorrulesCompact MDL reference and command guide
Continue.dev--tool continue.continue/config.jsonCustom commands and slash commands
Windsurf--tool windsurf.windsurfrulesCodeium’s AI with MDL rules
Aider--tool aider.aider.conf.ymlTerminal-based AI pair programming
GitHub Copilot(universal)AGENTS.md, .ai-context/Reads AGENTS.md and project context automatically
Universal(always created)AGENTS.md, .ai-context/Works with all tools
# List all supported tools
mxcli init --list-tools

How it works at a glance

The workflow is the same regardless of which AI tool you use:

  1. Initialize – mxcli init creates config files, skills, and a dev container
  2. Open in dev container – sandboxes the AI so it only accesses your project
  3. Start the AI assistant – Claude Code, GitHub Copilot, Cursor, etc.
  4. Ask for changes in natural language – “Create a Customer entity with name and email”
  5. The AI explores – it runs SHOW, DESCRIBE, and SEARCH commands via mxcli
  6. The AI writes MDL – guided by the skill files installed in your project
  7. The AI validates – mxcli check catches syntax errors before anything is applied
  8. The AI executes – the MDL script is run against your .mpr file
  9. You review in Studio Pro – open the project and verify the result

The next pages cover each tool in detail, starting with Claude Code.

Claude Code Integration

Claude Code is the primary AI integration for mxcli. It gets the deepest support: a dedicated configuration directory, project-level context in CLAUDE.md, skill files that teach Claude MDL patterns, and slash commands for common operations.

Initializing a project

Claude Code is the default tool, so you don’t need to specify a flag:

mxcli init /path/to/my-mendix-project

This is equivalent to:

mxcli init --tool claude /path/to/my-mendix-project

What gets created

After running mxcli init, your project directory gains several new entries:

my-mendix-project/
├── CLAUDE.md                    # Project context for Claude Code
├── AGENTS.md                    # Universal AI assistant guide
├── .claude/
│   ├── settings.json            # Claude Code settings
│   ├── commands/                # Slash commands (/create-entity, etc.)
│   └── lint-rules/              # Starlark lint rules
├── .ai-context/
│   ├── skills/                  # MDL pattern guides (shared by all tools)
│   └── examples/                # Example MDL scripts
├── .devcontainer/
│   ├── devcontainer.json        # Dev container configuration
│   └── Dockerfile               # Container image with mxcli, JDK, Docker
├── mxcli                        # CLI binary (copied into project)
└── app.mpr                      # Your Mendix project (already existed)

CLAUDE.md

The CLAUDE.md file gives Claude Code project-level context. It describes what mxcli is, lists the available MDL commands, and tells Claude to read the skill files before writing MDL. This file is automatically read by Claude Code when it starts.

Skills

The .claude/skills/ directory (and .ai-context/skills/ for the universal copy) holds one directory per skill, each with a SKILL.md. For example, write-microflows/SKILL.md explains microflow syntax, common mistakes, and a validation checklist. Claude Code discovers them from .claude/skills/ automatically and reads the relevant one before generating MDL, which dramatically improves output quality.

Commands

The .claude/commands/ directory contains slash commands that you can invoke from within Claude Code. These provide shortcuts for common operations.

Setting up the dev container

The dev container is the recommended way to work with Claude Code. It sandboxes the AI so it can only access your project files, and it comes pre-configured with everything you need.

What’s installed in the container

ComponentPurpose
mxcliMendix CLI (copied into project root)
MxBuild / mxMendix project validation and building
JDK 21 (Adoptium)Required by MxBuild
Docker-in-DockerRunning Mendix apps locally with mxcli docker
Node.jsPlaywright testing support
PostgreSQL clientDatabase connectivity
Claude CodeAuto-installed when the container starts

Opening the dev container

  1. Open your project folder in VS Code
  2. VS Code detects the .devcontainer/ directory and shows a notification
  3. Click “Reopen in Container” (or use the command palette: Dev Containers: Reopen in Container)
  4. Wait for the container to build (first time takes a few minutes)
  5. Once inside the container, open a terminal

Starting Claude Code

With the dev container running, open a terminal in VS Code and start Claude:

claude

Claude Code now has access to your project files, the mxcli binary, and all the skill files. You can start asking it to do things.

How Claude works with your project

Claude follows a consistent pattern when you ask it to modify your Mendix project:

1. Explore

Claude uses mxcli commands to understand your project before making changes:

-- What modules exist?
LIST MODULES;

-- What entities are in this module?
LIST ENTITIES IN Sales;

-- What does this entity look like?
DESCRIBE ENTITY Sales.Customer;

-- What microflows exist?
LIST MICROFLOWS IN Sales;

-- Search for something specific
SEARCH 'validation';

2. Read the relevant skill

Before writing MDL, Claude reads the appropriate skill file. If you ask for a microflow, it reads write-microflows.md. If you ask for a page, it reads create-page.md. The skills contain syntax references, examples, and validation checklists.

3. Write MDL

Claude generates an MDL script based on what it learned from the project and the skill files:

/** Customer master data */
@Position(100, 100)
CREATE PERSISTENT ENTITY Sales.Customer (
    Name: String(200) NOT NULL,
    Email: String(200) NOT NULL,
    Phone: String(50),
    IsActive: Boolean DEFAULT true
);

4. Validate

Claude checks the script for syntax errors and reference issues:

./mxcli check script.mdl
./mxcli check script.mdl -p app.mpr --references

5. Execute

If validation passes, Claude runs the script against your project:

./mxcli -p app.mpr -c "EXECUTE SCRIPT 'script.mdl'"

6. Verify

Claude can run a full project check using the Mendix build tools:

./mxcli docker check -p app.mpr

Example interaction

Here is a typical conversation with Claude Code:

You: Create a Customer entity in the Sales module with name, email, and phone. Then create an overview page that shows all customers in a data grid.

Claude (explores the project):

./mxcli -p app.mpr -c "LIST MODULES"
./mxcli -p app.mpr -c "LIST ENTITIES IN Sales"
./mxcli -p app.mpr -c "LIST PAGES IN Sales"

Claude (reads skills, writes MDL, validates, and executes):

mdl 1;
/** Customer contact information */
@Position(100, 100)
CREATE PERSISTENT ENTITY Sales.Customer (
    Name: String(200) NOT NULL,
    Email: String(200) NOT NULL,
    Phone: String(50),
    IsActive: Boolean DEFAULT true,
    CreatedAt: DateTime DEFAULT '[%CurrentDateTime%]'
);

/** Customer overview page with data grid */
CREATE PAGE Sales.Customer_Overview
(
  Title: 'Customers',
  Layout: Atlas_Core.Atlas_Default
)
{
  LAYOUTGRID mainGrid {
    ROW {
      COLUMN (DesktopWidth: 12) {
        DATAGRID dgCustomers (
          DataSource: DATABASE FROM Sales.Customer SORT BY Name ASC
        ) {
          COLUMN (Attribute: Name, Caption: 'Name')
          COLUMN (Attribute: Email, Caption: 'Email')
          COLUMN (Attribute: Phone, Caption: 'Phone')
          COLUMN (Attribute: IsActive, Caption: 'Active')
        }
      }
    }
  }
};

Claude validates the script, executes it, and reports back. You can then open the project in Studio Pro to review the result.

Tips for working with Claude Code

  • Be specific about module names. Say “Create a Customer entity in the Sales module” rather than just “Create a Customer entity.”
  • Mention existing elements. If you want an association to an existing entity, name it: “Link Order to the existing Sales.Customer entity.”
  • Let Claude explore first. If you’re asking for changes to an existing project, Claude will run SHOW and DESCRIBE commands to understand what’s already there. This leads to better results than trying to describe the current state yourself.
  • Review in Studio Pro. After Claude makes changes, open the project in Studio Pro to verify everything looks right visually.
  • Use mxcli docker check to catch issues that mxcli check alone might miss. The Mendix build tools perform deeper validation.

Next steps

If you use other AI tools alongside Claude Code, see Other AI tools (GitHub Copilot, OpenCode, Cursor, Continue.dev, Windsurf). To understand how skills work in detail, see Skills and CLAUDE.md.

OpenCode Integration

OpenCode is a fully supported AI integration for mxcli. It gets deep support: a dedicated configuration directory, project-level context in AGENTS.md, skill files that teach the AI MDL patterns, slash commands for common operations, and Starlark lint rules.

Initializing a project

Use the --tool opencode flag:

mxcli init --tool opencode /path/to/my-mendix-project

To set up both OpenCode and Claude Code together:

mxcli init --tool opencode --tool claude /path/to/my-mendix-project

What gets created

After running mxcli init --tool opencode, your project directory gains:

my-mendix-project/
├── AGENTS.md                    # Universal AI assistant guide (OpenCode reads this)
├── opencode.json                # OpenCode configuration file
├── .opencode/
│   ├── commands/                # Slash commands (/create-entity, etc.)
│   └── skills/                  # MDL pattern guides (OpenCode skill format)
│       ├── write-microflows/
│       │   └── SKILL.md
│       ├── create-page/
│       │   └── SKILL.md
│       └── ...
├── .claude/
│   └── lint-rules/              # Starlark lint rules (shared with mxcli lint)
├── .ai-context/
│   ├── skills/                  # Shared skill files (universal copy)
│   └── examples/                # Example MDL scripts
├── .devcontainer/
│   ├── devcontainer.json        # Dev container configuration
│   └── Dockerfile               # Container image with mxcli, JDK, Docker
├── mxcli                        # CLI binary (copied into project)
└── app.mpr                      # Your Mendix project (already existed)

opencode.json

The opencode.json file is OpenCode’s primary configuration. Its instructions are AGENTS.md alone:

{
  "$schema": "https://opencode.ai/config.json",
  "instructions": [
    "AGENTS.md"
  ]
}

The skills are not listed there. OpenCode inlines every instruction file’s whole body into the system prompt, so listing the skill trees put about 2.7 MB of skill text in front of every session. OpenCode discovers the skills in .opencode/skills/ itself and loads a skill’s body only when its description matches the task.

Skills

Skills live in .opencode/skills/<name>/SKILL.md and use YAML frontmatter that OpenCode understands. The description is the skill’s own, with its “Use when…” clause, since that is what OpenCode matches a task against:

---
name: write-microflows
description: 'Microflow syntax reference in MDL — … Use before writing any CREATE MICROFLOW …'
compatibility: opencode
---

# Writing Microflows
...

Each skill covers a specific area: microflow syntax, page patterns, security setup, and so on. OpenCode reads the relevant skill before generating MDL, which significantly improves output quality.

Commands

The .opencode/commands/ directory contains slash commands available inside OpenCode. These mirror the Claude Code commands: /create-entity, /create-microflow, /create-page, /lint, and others.

Lint rules

Lint rules live in .claude/lint-rules/ regardless of which tool is selected — this is where mxcli lint looks for custom Starlark rules. OpenCode init writes the rules there so mxcli lint works the same way for both tool choices.

Setting up the dev container

The dev container setup is identical to the Claude Code workflow. Open your project folder in VS Code, click “Reopen in Container” when prompted, and wait for the container to build.

What’s installed in the container

ComponentPurpose
mxcliMendix CLI (copied into project root)
MxBuild / mxMendix project validation and building
JDK 21 (Adoptium)Required by MxBuild
Docker-in-DockerRunning Mendix apps locally with mxcli docker
Node.jsPlaywright testing support
PostgreSQL clientDatabase connectivity

Starting OpenCode

With the dev container running, open a terminal in VS Code and start OpenCode:

opencode

OpenCode now has access to your project files, the mxcli binary, the skill files in .opencode/skills/, and the commands in .opencode/commands/.

How OpenCode works with your project

The workflow mirrors Claude Code exactly:

1. Explore

OpenCode uses mxcli commands to understand the project before making changes:

-- What modules exist?
LIST MODULES;

-- What entities are in this module?
LIST ENTITIES IN Sales;

-- What does this entity look like?
DESCRIBE ENTITY Sales.Customer;

-- What microflows exist?
LIST MICROFLOWS IN Sales;

-- Search for something specific
SEARCH 'validation';

2. Read the relevant skill

Before writing MDL, OpenCode reads the appropriate skill file from .opencode/skills/. If you ask for a microflow, it reads the write-microflows skill. If you ask for a page, it reads create-page.

3. Write MDL

OpenCode generates an MDL script based on the project context and skill guidance:

/** Customer master data */
@Position(100, 100)
CREATE PERSISTENT ENTITY Sales.Customer (
    Name: String(200) NOT NULL,
    Email: String(200) NOT NULL,
    Phone: String(50),
    IsActive: Boolean DEFAULT true
);

4. Validate

./mxcli check script.mdl
./mxcli check script.mdl -p app.mpr --references

5. Execute

./mxcli -p app.mpr -c "EXECUTE SCRIPT 'script.mdl'"

6. Verify

./mxcli docker check -p app.mpr

Adding OpenCode to an existing project

If you already ran mxcli init for another tool and want to add OpenCode support:

mxcli add-tool opencode

This creates .opencode/, opencode.json, and the lint rules without touching any existing configuration.

Tips for working with OpenCode

  • Be specific about module names. Say “Create a Customer entity in the Sales module” rather than just “Create a Customer entity.”
  • Mention existing elements. If you want an association to an existing entity, name it: “Link Order to the existing Sales.Customer entity.”
  • Let the AI explore first. OpenCode will run SHOW and DESCRIBE commands to understand what’s already in the project. This leads to better results.
  • Review in Studio Pro. After changes are applied, open the project in Studio Pro to verify the result visually.
  • Use mxcli docker check to catch issues that mxcli check alone might miss.

Next steps

To understand what the skill files contain and how they guide AI behavior, see Skills and CLAUDE.md. For other supported tools, see Other AI tools (Cursor, Continue.dev, Windsurf, OpenCode).

Other AI tools

Claude Code is the default integration, but mxcli also supports OpenCode, Cursor, Continue.dev, Windsurf, Aider, and Mistral Vibe. Each tool gets its own configuration file that teaches the AI about MDL syntax and mxcli commands.

Initializing for a specific tool

Use the --tool flag to specify which AI tool you use:

# OpenCode
mxcli init --tool opencode /path/to/my-mendix-project

# Cursor
mxcli init --tool cursor /path/to/my-mendix-project

# Continue.dev
mxcli init --tool continue /path/to/my-mendix-project

# Windsurf
mxcli init --tool windsurf /path/to/my-mendix-project

# Aider
mxcli init --tool aider /path/to/my-mendix-project

# Mistral Vibe
mxcli init --tool vibe /path/to/my-mendix-project

Setting up multiple tools

You can configure several tools at once. This is useful if different team members use different editors, or if you want to try several tools on the same project:

# Multiple tools
mxcli init --tool claude --tool cursor /path/to/my-mendix-project

# All supported tools at once
mxcli init --all-tools /path/to/my-mendix-project

Adding a tool to an existing project

If you already ran mxcli init and want to add support for another tool without re-initializing:

mxcli add-tool cursor
mxcli add-tool windsurf

This creates the tool-specific config file without touching the existing setup.

What each tool gets

Every tool gets the universal files that are always created:

FilePurpose
AGENTS.mdComprehensive AI assistant guide (works with any tool)
.ai-context/skills/MDL pattern guides shared by all tools
.ai-context/examples/Example MDL scripts
.devcontainer/Dev container configuration
mxcliCLI binary

On top of the universal files, each tool gets its own configuration:

ToolConfig FileContents
Claude Code.claude/, CLAUDE.mdSettings, skills, commands, lint rules, project context
OpenCode.opencode/, opencode.jsonSkills, commands, lint rules, project context
GitHub Copilot.github/copilot-instructions.md, AGENTS.mdProject-level instructions auto-loaded by Copilot in VS Code
Cursor.cursorrulesCompact MDL reference and mxcli command guide
Continue.dev.continue/config.jsonCustom commands and slash commands
Windsurf.windsurfrulesMDL rules for Codeium’s AI
Aider.aider.conf.ymlYAML configuration for Aider
Mistral Vibe.vibe/Config, system prompt, and SKILL.md skills

Tool details

OpenCode

OpenCode receives full integration on par with Claude Code: dedicated skills in .opencode/skills/, slash commands in .opencode/commands/, and Starlark lint rules in .claude/lint-rules/. See the dedicated OpenCode Integration page for the complete walkthrough.

mxcli init --tool opencode /path/to/project

GitHub Copilot

GitHub Copilot has dedicated support via --tool copilot, which generates .github/copilot-instructions.md — Copilot’s project-level instructions file that’s automatically loaded in VS Code.

mxcli init --tool copilot /path/to/project

The generated file is compact (Copilot has a smaller instruction context window than Claude Code) and points to AGENTS.md and .ai-context/skills/ for full reference material. It includes the critical ./mxcli (not mxcli) rule, quick command examples, and MDL syntax reminders.

For many organizations, Copilot is the default AI assistant as part of their Microsoft/GitHub enterprise agreement. To use: open the project in VS Code with the GitHub Copilot extension, open Copilot Chat (Ctrl+Shift+I), and ask for Mendix changes. Copilot will reference .github/copilot-instructions.md, AGENTS.md, and the skill files for MDL syntax guidance.

Cursor

Cursor reads its instructions from .cursorrules in the project root. The file mxcli generates contains a compact MDL syntax reference and a list of mxcli commands the AI can use. Cursor’s Composer and Chat features will reference this file automatically.

mxcli init --tool cursor /path/to/project

Created files:

  • .cursorrules – MDL syntax, command reference, and conventions
  • AGENTS.md – universal guide (Cursor also reads this)
  • .ai-context/skills/ – shared skill files

To use: open the project in Cursor, then use Composer (Ctrl+I) or Chat (Ctrl+L) to ask for Mendix changes.

Continue.dev

Continue.dev uses a JSON configuration file with custom commands. The generated config tells Continue about mxcli and provides slash commands for common MDL operations.

mxcli init --tool continue /path/to/project

Created files:

  • .continue/config.json – custom commands, slash command definitions
  • AGENTS.md and .ai-context/skills/ – universal files

To use: open the project in VS Code with the Continue extension, then use the Continue sidebar to ask for changes.

Windsurf

Windsurf reads .windsurfrules from the project root. The generated file contains MDL rules and mxcli command documentation tailored for Codeium’s AI.

mxcli init --tool windsurf /path/to/project

Created files:

  • .windsurfrules – MDL rules and command reference
  • AGENTS.md and .ai-context/skills/ – universal files

To use: open the project in Windsurf, then use Cascade to ask for Mendix changes.

Aider

Aider is a terminal-based AI pair programming tool. The generated YAML config tells Aider about the project structure and available commands.

mxcli init --tool aider /path/to/project

Created files:

  • .aider.conf.yml – Aider configuration
  • AGENTS.md and .ai-context/skills/ – universal files

To use: run aider in the project directory from the terminal.

Mistral Vibe

Mistral Vibe is a terminal-based AI coding agent by Mistral AI. It uses .vibe/config.toml for configuration, .vibe/prompts/ for system prompts, and .vibe/skills/ for SKILL.md-based skills.

mxcli init --tool vibe /path/to/project

Created files:

  • .vibe/config.toml – project configuration with system prompt reference
  • .vibe/prompts/mendix-mdl.md – system prompt with project context and mxcli commands
  • .vibe/skills/write-microflows/SKILL.md – microflow syntax and rules
  • .vibe/skills/create-page/SKILL.md – page/widget syntax
  • .vibe/skills/check-syntax/SKILL.md – validation workflow
  • .vibe/skills/explore-project/SKILL.md – project query commands
  • AGENTS.md and .ai-context/skills/ – universal files

To use: run vibe in the project directory from the terminal. Vibe auto-discovers skills in .vibe/skills/ and loads the system prompt from .vibe/prompts/.

The universal format: AGENTS.md

Regardless of which tool you pick, mxcli always creates AGENTS.md and the .ai-context/ directory. These use a universal format that most AI tools understand:

  • AGENTS.md is a comprehensive guide placed in the project root. It describes mxcli, lists MDL commands, and explains the development workflow. Many AI tools (GitHub Copilot, OpenAI Codex, and others) automatically read markdown files in the project root.
  • .ai-context/skills/ contains the same skill files that Claude Code gets, but in the shared location. Any tool that can read project files can reference these.

This means even if your AI tool is not in the supported list, it can still benefit from mxcli init. The AGENTS.md file and skill directory provide enough context for any AI assistant to work with MDL.

Listing supported tools

To see all tools mxcli knows about:

mxcli init --list-tools

Next steps

To understand what the skill files contain and how they guide AI behavior, see Skills and CLAUDE.md.

Skills and CLAUDE.md

Skills teach AI assistants how to write correct MDL. Each skill covers a specific topic – creating pages, writing microflows, managing security – and contains syntax references, examples, and validation checklists. When an AI assistant needs to generate MDL, it reads the relevant skill first, which dramatically improves output quality.

The format

Skills follow the Agent Skills standard: each one is a directory holding a SKILL.md, whose YAML frontmatter says what it covers and when to use it.

.ai-context/skills/
└── write-microflows/
    └── SKILL.md
---
name: write-microflows
description: Microflow syntax reference in MDL -- every activity type, control flow,
  expressions, and the mistakes that fail `mxcli check`. Use before writing any
  CREATE MICROFLOW, and when debugging a microflow syntax error.
---

# Mendix Microflow Skill
...

The description is what makes a skill findable. A tool that reads skills loads every description up front – a line or two each – and pulls in the full body only when one is actually needed. So the assistant knows the whole set exists without any of them costing context until used, and without a hand-maintained index that can fall out of step.

Where skills live

mxcli init installs the same set in two places:

LocationUsed by
.ai-context/skills/<name>/SKILL.mdAll tools – the vendor-neutral copy, referenced by the generated OpenCode, Cursor, Continue, Windsurf and Aider configs
.claude/skills/<name>/SKILL.mdClaude Code, which discovers skills from this path automatically

The .claude/skills/ copy exists because that is the only directory Claude Code scans, one level deep. Both copies are refreshed from the mxcli binary on every session by mxcli init --sync-skills, so they cannot drift apart.

If you are upgrading a project created by an older mxcli, the flat <name>.md files it used to write are retired automatically on the next refresh. Skill files you added yourself are left alone.

Available skills

mxcli init installs the following skill files:

Skill FileTopic
generate-domain-model/SKILL.mdEntity, attribute, and association syntax
write-microflows/SKILL.mdMicroflow syntax, activities, common mistakes
create-page/SKILL.mdPage and widget syntax reference
alter-page/SKILL.mdALTER PAGE/SNIPPET for modifying existing pages
overview-pages/SKILL.mdCRUD page patterns (overview + edit)
master-detail-pages/SKILL.mdMaster-detail page patterns
manage-security/SKILL.mdModule roles, user roles, access control, GRANT/REVOKE
manage-navigation/SKILL.mdNavigation profiles, home pages, menus
demo-data/SKILL.mdMendix ID system, association storage, demo data insertion
xpath-constraints/SKILL.mdXPath syntax in WHERE clauses, nested predicates
database-connections/SKILL.mdExternal database connections from microflows
check-syntax/SKILL.mdPre-flight validation checklist
organize-project/SKILL.mdFolders, MOVE command, project structure conventions
test-microflows/SKILL.mdTest annotations, file formats, Docker setup
patterns-data-processing/SKILL.mdDelta merge, batch processing, list operations

What a skill file contains

A typical skill file has four sections:

1. Syntax reference

The core MDL syntax for the topic, with all available options and keywords:

-- From write-microflows.md:
CREATE MICROFLOW Module.Name(
    $param: Module.Entity
) RETURNS Boolean AS $result
BEGIN
    -- activities here
END;

2. Examples

Complete, working MDL examples that the AI can use as templates:

-- From create-page.md:
CREATE PAGE Sales.Customer_Overview
(
  Title: 'Customers',
  Layout: Atlas_Core.Atlas_Default
)
{
  DATAGRID dgCustomers (
    DataSource: DATABASE FROM Sales.Customer SORT BY Name ASC
  ) {
    COLUMN (Attribute: Name, Caption: 'Name')
  }
};

3. Common mistakes

A list of errors the AI should avoid. For example, write-microflows.md warns against creating empty list variables as loop sources, and create-page.md documents required widget properties that are easy to forget.

4. Validation checklist

Steps the AI should follow after writing MDL to confirm correctness:

# Syntax check only (--no-project: do not auto-detect an .mpr in this directory)
./mxcli check script.mdl --no-project

# Syntax + reference validation
./mxcli check script.mdl -p app.mpr --references

CLAUDE.md

The CLAUDE.md file is specific to Claude Code. It sits in the project root and is automatically read by Claude when it starts. It provides:

  • Project overview – what the project is, which modules exist, and what mxcli is
  • Available commands – a summary of mxcli commands Claude can use
  • Rules – instructions like “always read the relevant skill file before writing MDL” and “always validate with mxcli check before executing”
  • Conventions – project-specific naming conventions, module structure, etc.

Think of CLAUDE.md as the “system prompt” for Claude Code in the context of your project. It sets the tone and establishes guardrails.

AGENTS.md

AGENTS.md serves the same purpose as CLAUDE.md but in a universal format. It is always created by mxcli init, regardless of which tool you selected. AI tools that don’t have their own config format (or that read markdown files from the project root) will pick up AGENTS.md automatically.

Adding custom skills

You can create your own skills to teach the AI about your project’s patterns and conventions. Add a directory with a SKILL.md to .ai-context/skills/ (and .claude/skills/ if you use Claude Code):

.ai-context/skills/
├── write-microflows/SKILL.md         # Built-in (installed by mxcli init)
├── create-page/SKILL.md              # Built-in
├── our-naming-conventions/SKILL.md   # Custom: your team's naming rules
├── order-processing-pattern/SKILL.md # Custom: how orders work in your app
└── api-integration-guide/SKILL.md    # Custom: how to call external APIs

mxcli init --sync-skills only rewrites the skills mxcli itself ships, so your own directories survive every upgrade. It also refreshes the bundled lint rules and the mxcli section of CLAUDE.md — see Syncing with Updates.

A custom skill is a markdown document with two lines of frontmatter. Write the body the way you would explain something to a new team member, and write the description so an assistant can tell from it alone whether this is the skill for the job:

---
name: order-processing-pattern
description: How orders are processed in this application -- validation rules,
  status enumeration, logging node and confirmation email. Use when writing or
  changing any microflow that touches an Order.
---

# Order Processing Pattern

When creating microflows that process orders in our application, follow these rules:

1. Always validate the order has at least one OrderLine
2. Use the Sales.OrderStatus enumeration for status tracking
3. Log to the 'OrderProcessing' node at INFO level
4. Send a confirmation email via Sales.SendNotification

## Example

\```sql
CREATE MICROFLOW Sales.ACT_Order_Submit($order: Sales.Order)
RETURNS Boolean AS $success
BEGIN
    -- validation, processing, notification...
END;
\```

The AI will read your custom skills alongside the built-in ones, learning your project’s specific patterns.

How the AI uses skills

The workflow is straightforward:

  1. You ask for a change: “Create a page that shows all orders”
  2. The AI determines which skill is relevant (in this case, create-page.md and overview-pages.md)
  3. The AI reads the skill files
  4. The AI writes MDL following the syntax and patterns in the skill
  5. The AI validates with mxcli check
  6. The AI executes the script

Skills act as a guardrail. Without them, AI assistants tend to guess at MDL syntax and get details wrong. With skills, the AI has a precise reference to follow, and the output is correct far more often.

Next steps

Now that you understand how skills guide AI behavior, see The MDL + AI Workflow for the complete recommended workflow from project initialization to review in Studio Pro.

The MDL + AI Workflow

This page walks through the complete recommended workflow for using mxcli with an AI assistant, from project setup to reviewing the result in Studio Pro.

The ten-step workflow

Step 1: Initialize the project

Start by running mxcli init on your Mendix project:

mxcli init /path/to/my-mendix-project

This creates the configuration files, skill documents, and dev container setup. If you use a tool other than Claude Code, specify it with --tool:

mxcli init --tool cursor /path/to/my-mendix-project

Step 2: Open in a dev container

Open the project folder in VS Code (or Cursor). VS Code will detect the .devcontainer/ directory and prompt you to reopen in a container. Click “Reopen in Container”.

The dev container provides a sandboxed environment where the AI assistant can only access your project files. It comes with mxcli, JDK, Docker, and your AI tool pre-installed.

Step 3: Start your AI assistant

Once inside the dev container, open a terminal and start your AI tool:

# Claude Code
claude

# Or use Cursor's Composer, Continue.dev's sidebar, etc.

Step 4: Describe what you want

Tell the AI what you need in plain language. Be specific about module names and mention existing elements:

“Create a Product entity in the Sales module with name, price, and description. Add an association to the existing Sales.Category entity.”

Better prompts lead to better results. A few tips:

  • Name the module explicitly: “in the Sales module”
  • Reference existing elements: “linked to the existing Customer entity”
  • Describe the behavior, not the implementation: “a microflow that validates the email format” rather than “an IF statement that checks for @ and .”

Step 5: The AI explores your project

Before making changes, the AI uses mxcli to understand the current state of your project:

-- See what modules exist
LIST MODULES;

-- Check what's already in the Sales module
DESCRIBE STRUCTURE IN Sales;

-- Look at an existing entity for context
DESCRIBE ENTITY Sales.Category;

-- Search for related elements
SEARCH 'product';

This exploration step is important. The AI needs to know what entities, associations, and microflows already exist so it can write MDL that fits with your project.

Step 6: The AI writes MDL

Guided by the skill files and its exploration, the AI writes an MDL script:

mdl 1;
/** Product catalog item */
@Position(300, 100)
CREATE PERSISTENT ENTITY Sales.Product (
    Name: String(200) NOT NULL,
    Description: String(0),
    Price: Decimal NOT NULL DEFAULT 0,
    IsActive: Boolean DEFAULT true
);

/** Link products to categories */
CREATE ASSOCIATION Sales.Product_Category
    FROM Sales.Product
    TO Sales.Category
    TYPE Reference
    OWNER Default;

Step 7: The AI validates

Before executing, the AI checks for errors:

# Check syntax
./mxcli check script.mdl

# Check syntax and verify that referenced elements exist
./mxcli check script.mdl -p app.mpr --references

If there are errors, the AI fixes them and re-validates. This cycle happens automatically – you don’t need to intervene.

Step 8: The AI executes

Once validation passes, the AI runs the script against your project:

./mxcli -p app.mpr -c "EXECUTE SCRIPT 'script.mdl'"

The changes are written directly to your .mpr file.

Step 9: Deep validation

For thorough validation, the AI can run the Mendix build tools:

./mxcli docker check -p app.mpr

This uses MxBuild (the same engine Studio Pro uses) to check the project for consistency errors, missing references, and other issues that mxcli check alone might miss.

Step 10: Review in Studio Pro

Open your project in Mendix Studio Pro to review the changes visually. Check that:

  • Entities appear correctly in the domain model
  • Pages render as expected
  • Microflow logic looks right in the visual editor
  • Security settings are correct

A complete example session

Here is what a full session looks like when you ask Claude Code to build a customer management feature:

You: “Create a customer management feature in the CRM module. I need a Customer entity with name, email, phone, and status. Create an overview page with a data grid and a popup edit form. Add a microflow that validates the email before saving.”

Claude Code:

  1. Runs LIST MODULES and LIST ENTITIES IN CRM to understand the project
  2. Reads generate-domain-model.md, overview-pages.md, and write-microflows.md skills
  3. Writes a script with the entity, enumeration, pages, and microflow
  4. Validates with mxcli check
  5. Executes the script
  6. Runs mxcli docker check to verify
  7. Reports back with a summary of what was created

The entire interaction takes a few minutes. The equivalent work in Studio Pro would take considerably longer.

Tips for best results

Be specific about what exists

If you’re working with an existing project, mention the elements that are already there:

“Add a ShippingAddress field to the existing CRM.Customer entity and update the CRM.Customer_Edit page to include it.”

Let the AI explore

Don’t try to describe your entire project upfront. The AI is good at exploring. A prompt like “look at the Sales module and add a discount field to orders” is enough – the AI will figure out the entity name, existing fields, and page structure on its own.

Iterate

You don’t have to get everything right in one prompt. Start with the entity, review it, then ask for pages, then microflows. Small, focused requests tend to produce better results than one massive prompt.

Use mxcli docker check for final validation

mxcli check validates MDL syntax and references, but it doesn’t catch everything. The Mendix build tools (mxcli docker check) perform the same validation that Studio Pro does. Use it as a final gate before considering the work done.

Version control

Always work on a copy of your project or use version control. mxcli writes directly to your .mpr file. If something goes wrong, you want to be able to revert. For MPR v2 projects (Mendix 10.18+), the individual document files in mprcontents/ work well with Git.

Add custom skills for your project

If your project has specific patterns or conventions, write them down as skill files. For example, if every entity needs an audit trail (CreatedBy, CreatedAt, ChangedBy, ChangedAt), create a skill that documents this convention. The AI will follow it consistently.

MDL by Example

This part presents complete, self-contained MDL examples that show how the language comes together for real use cases. Each example is ready to run against a Mendix project.

Where Part III covers MDL syntax one statement at a time, these examples show full features built end to end – domain model, business logic, pages, and security working together.

Examples

ExampleWhat It Shows
CRM ModuleFull CRUD feature: entities with documented attributes, associations, validation microflow, overview and edit pages, security
REST IntegrationCall external APIs from microflows: GET/POST, headers, authentication, error handling, JSON responses
Data Import PipelineConnect to an external database, explore schema, import data with association linking, generate connectors
Master-Detail PageGallery with selection binding, detail form, save/cancel actions
Modifying Existing PagesALTER PAGE to add fields, change buttons, replace sections, drop widgets
Validation PatternTwo-microflow validation with field-level feedback
Role-Based SecurityModule roles, user roles, entity access with XPath row-level constraints, demo users
View EntitiesOQL-backed aggregation with JOIN, filtered retrieval in microflows

CRM Module

A complete customer management feature: domain model, validation, CRUD pages, and security – all in one script.

Domain Model

mdl 1;
-- Enumerations first (referenced by entities)
CREATE ENUMERATION CRM.CustomerStatus (
  Active 'Active',
  Inactive 'Inactive',
  Suspended 'Suspended'
);

CREATE ENUMERATION CRM.ContactType (
  Email 'Email',
  Phone 'Phone',
  Visit 'Visit'
);

-- Entities with per-attribute documentation
/** Customer master data */
@Position(100, 100)
CREATE PERSISTENT ENTITY CRM.Customer (
  /** Auto-generated unique identifier */
  CustomerId: AutoNumber NOT NULL UNIQUE DEFAULT 1,
  /** Full legal name */
  Name: String(200) NOT NULL ERROR MESSAGE 'Customer name is required',
  /** Primary contact email */
  Email: String(200) UNIQUE ERROR MESSAGE 'Email already exists',
  /** Phone number in international format */
  Phone: String(50),
  /** Current account balance */
  Balance: Decimal DEFAULT 0,
  /** Whether the account is active */
  IsActive: Boolean DEFAULT TRUE,
  /** Current lifecycle status */
  Status: Enumeration(CRM.CustomerStatus) DEFAULT 'Active',
  /** Free-form notes about this customer */
  Notes: String(unlimited)
)
INDEX (Name)
INDEX (Email);

/** Record of a customer interaction */
@Position(400, 100)
CREATE PERSISTENT ENTITY CRM.ContactLog (
  /** Date and time of the interaction */
  ContactDate: DateTime NOT NULL,
  /** Type of interaction */
  ContactType: Enumeration(CRM.ContactType) DEFAULT 'Email',
  /** Summary of what was discussed */
  Summary: String(2000) NOT NULL ERROR MESSAGE 'Summary is required',
  /** Follow-up needed? */
  FollowUpRequired: Boolean DEFAULT FALSE
);

-- Associations
CREATE ASSOCIATION CRM.ContactLog_Customer
  FROM CRM.ContactLog TO CRM.Customer
  TYPE Reference OWNER Default;

Validation Microflow

The two-microflow pattern: a validation microflow returns field-level feedback, and an action microflow calls it before saving.

mdl 1;
CREATE MICROFLOW CRM.VAL_Customer ($Customer: CRM.Customer)
RETURNS Boolean AS $IsValid
BEGIN
  DECLARE $IsValid Boolean = true;

  IF trim($Customer/Name) = '' THEN
    SET $IsValid = false;
    VALIDATION FEEDBACK $Customer/Name MESSAGE 'Name cannot be empty';
  END IF;

  IF $Customer/Email != empty AND not(contains($Customer/Email, '@')) THEN
    SET $IsValid = false;
    VALIDATION FEEDBACK $Customer/Email MESSAGE 'Enter a valid email address';
  END IF;

  IF $Customer/Balance < 0 THEN
    SET $IsValid = false;
    VALIDATION FEEDBACK $Customer/Balance MESSAGE 'Balance cannot be negative';
  END IF;

  RETURN $IsValid;
END;

CREATE MICROFLOW CRM.ACT_Customer_Save ($Customer: CRM.Customer)
RETURNS Boolean AS $IsValid
BEGIN
  $IsValid = CALL MICROFLOW CRM.VAL_Customer(Customer = $Customer);

  IF $IsValid THEN
    COMMIT $Customer;
    CLOSE PAGE;
  END IF;

  RETURN $IsValid;
END;

Pages

mdl 1;
-- NewEdit page with validation (first: the overview opens it)
CREATE PAGE CRM.Customer_NewEdit (
  Params: ( $Customer: CRM.Customer ),
  Title: 'Customer',
  Layout: Atlas_Core.PopupLayout
) {
  LAYOUTGRID mainGrid {
    ROW {
      COLUMN (DesktopWidth: AutoFill) {
        DATAVIEW dataView1 (DataSource: $Customer) {
          TEXTBOX txtName (Label: 'Name', Attribute: Name)
          TEXTBOX txtEmail (Label: 'Email', Attribute: Email)
          TEXTBOX txtPhone (Label: 'Phone', Attribute: Phone)
          TEXTAREA txtNotes (Label: 'Notes', Attribute: Notes)
          FOOTER {
            ACTIONBUTTON btnSave (
              Caption: 'Save',
              Action: CALL MICROFLOW CRM.ACT_Customer_Save,
              ButtonStyle: Success
            )
            ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
          }
        }
      }
    }
  }
};

-- Overview page with data grid
CREATE PAGE CRM.Customer_Overview (
  Title: 'Customers',
  Layout: Atlas_Core.Atlas_Default
) {
  DATAGRID dgCustomers (DataSource: DATABASE CRM.Customer, Selection: Single) {
    COLUMN (Attribute: Name, Caption: 'Name') { TEXTFILTER fName }
    COLUMN (Attribute: Email, Caption: 'Email') { TEXTFILTER fEmail }
    COLUMN (Attribute: Phone, Caption: 'Phone')
    COLUMN (Attribute: Status, Caption: 'Status')
    COLUMN (Attribute: IsActive, Caption: 'Active')
    CONTROLBAR {
      ACTIONBUTTON btnNew (Caption: 'New', Action: CREATE OBJECT CRM.Customer THEN SHOW PAGE CRM.Customer_NewEdit, ButtonStyle: Primary)
    }
  }
};

Security

mdl 1;
-- Module roles
CREATE MODULE ROLE CRM.User;
CREATE MODULE ROLE CRM.Admin DESCRIPTION 'Full customer management access';

-- Entity access
GRANT CREATE, DELETE, READ *, WRITE * ON ENTITY CRM.Customer TO CRM.Admin;
GRANT CREATE, READ *, WRITE * ON ENTITY CRM.Customer TO CRM.User
  WHERE [IsActive = true];

GRANT CREATE, DELETE, READ *, WRITE * ON ENTITY CRM.ContactLog TO CRM.Admin;
GRANT CREATE, READ *, WRITE * ON ENTITY CRM.ContactLog TO CRM.User;

-- Document access
GRANT EXECUTE ON MICROFLOW CRM.ACT_Customer_Save TO CRM.User;
GRANT VIEW ON PAGE CRM.Customer_Overview TO CRM.User;
GRANT VIEW ON PAGE CRM.Customer_NewEdit TO CRM.User;

-- User roles
CREATE OR MODIFY USER ROLE CRMUser ( ModuleRoles: (System.User, CRM.User) );
CREATE OR MODIFY USER ROLE CRMAdmin ( ModuleRoles: (System.User, CRM.Admin) );

-- Demo users for testing
CREATE OR MODIFY DEMO USER 'crm_user' ( Password: 'CrmPassword1!', UserRoles: (CRMUser) );
CREATE OR MODIFY DEMO USER 'crm_admin' ( Password: 'CrmPassword1!', UserRoles: (CRMAdmin) );
ALTER APP SECURITY ( EnableDemoUsers: TRUE );

REST Integration

Calling external APIs from microflows – GET, POST, authentication, and error handling.

Simple GET

CREATE MICROFLOW Integration.FetchWebpage ()
RETURNS String AS $Content
BEGIN
  $Content = CALL REST SERVICE GET 'https://example.com/api/status' (
    Headers: ('Accept': 'application/json'),
    Timeout: 30,
  ) RETURNS String;
  RETURN $Content;
END;

GET with URL Parameters

CREATE MICROFLOW Integration.SearchProducts (
  $Query: String,
  $Page: Integer
)
RETURNS String AS $Response
BEGIN
  $Response = CALL REST SERVICE GET 'https://api.example.com/search?q={1}&page={2}' WITH (
    {1} = urlEncode($Query),
    {2} = toString($Page)
  ) (
    Headers: ('Accept': 'application/json'),
    Timeout: 60,
  ) RETURNS String;
  RETURN $Response;
END;

POST with JSON Body

CREATE MICROFLOW Integration.CreateCustomer (
  $Name: String,
  $Email: String
)
RETURNS String AS $Response
BEGIN
  $Response = CALL REST SERVICE POST 'https://api.example.com/customers' (
    Headers: ('Content-Type': 'application/json'),
    Body: TEMPLATE '{{"name": "{1}", "email": "{2}"}' WITH (
      {1} = $Name,
      {2} = $Email
    ),
    Timeout: 30,
  ) RETURNS String;
  RETURN $Response;
END;

POST a Body Built Elsewhere

When the body is already in a variable (or any other expression), write the expression. It sends the expression’s value: mxcli stores it as the template '{1}' with the expression as its parameter, which is what Studio Pro stores, and DESCRIBE prints it in that form. A URL written as an expression (@Integration.BaseUrl + '/orders') is stored the same way.

CREATE MICROFLOW Integration.SendOrder (
  $Order: Integration.Order
)
RETURNS String AS $Response
BEGIN
  DECLARE $Payload String = '{"number": "' + $Order/Number + '"}';
  $Response = CALL REST SERVICE POST @Integration.BaseUrl + '/orders' (
    Headers: ('Content-Type': 'application/json'),
    Body: $Payload,
    Timeout: 30,
  ) RETURNS String;
  RETURN $Response;
END;

Body: TEMPLATE '$Payload' is different: a template is text, so that sends the eight characters $Payload. An expression followed by WITH (…) has no {n} placeholders to fill and is refused as MDL-REST05.

POST a Binary Body (File Upload)

Mendix models a binary request body on the REST CALL activity, not on a consumed REST client document. The expression is the file document’s Contents member, and the content type goes on a header.

CREATE MICROFLOW Integration.UploadDocument (
  $Doc: Integration.UploadedFile
)
RETURNS Boolean AS $Ok
BEGIN
  DECLARE $Ok Boolean = false;
  $Response = CALL REST SERVICE POST 'https://api.example.com/documents' (
    Headers: ('ContentType': 'application/pdf'),
    Body: BINARY $Doc/Contents,
    Timeout: 300,
  ) RETURNS response;
  SET $Ok = $Response/StatusCode = 200;
  RETURN $Ok;
END;

RETURNS response binds a System.HttpResponse, so the microflow’s own return type must match what you do with it — returning $Response from a RETURNS String microflow is CE0117 at the end event.

$Doc must be a specialization of System.FileDocument.

A consumed REST client document has no binary body — its three body types (Rest$JsonBody, Rest$StringBody, Rest$ImplicitMappingBody) are all textual — so Body: file from $Doc in a CREATE CONSUMED REST SERVICE operation is refused as MDL-REST02. Downloading is unaffected: Response: file as $Doc works.

Basic Authentication

CREATE MICROFLOW Integration.FetchSecureData (
  $Username: String,
  $Password: String
)
RETURNS String AS $Response
BEGIN
  $Response = CALL REST SERVICE GET 'https://api.example.com/secure/data' (
    Headers: ('Accept': 'application/json'),
    Authentication: BASIC (Username: $Username, Password: $Password),
    Timeout: 30,
  ) RETURNS String;
  RETURN $Response;
END;

Error Handling

Use ON ERROR WITHOUT ROLLBACK to catch failures and return a fallback instead of rolling back the transaction:

CREATE MICROFLOW Integration.SafeAPICall (
  $Url: String
)
RETURNS Boolean AS $Success
BEGIN
  DECLARE $Success Boolean = false;

  $Response = CALL REST SERVICE GET $Url (
    Headers: ('Accept': 'application/json'),
    Timeout: 30,
  ) RETURNS String
    ON ERROR WITHOUT ROLLBACK BEGIN
      LOG ERROR NODE 'Integration' 'API call failed: ' + $Url;
      RETURN false;
    END ERROR;

  SET $Success = true;
  RETURN $Success;
END;

JSON Structure + Import Mapping

Map a JSON response to Mendix entities using a JSON structure and import mapping.

mdl 1;
-- Step 1: Define the JSON structure
CREATE JSON STRUCTURE Integration.JSON_Pet
  SAMPLE '{"id": 1, "name": "Fido", "status": "available"}';

-- Step 2: Create a non-persistent entity to hold the data
CREATE NON-PERSISTENT ENTITY Integration.PetResponse (
  PetId: Integer,
  Name: String,
  Status: String
);

-- Step 3: Create the import mapping
CREATE IMPORT MAPPING Integration.IMM_Pet
  WITH JSON STRUCTURE Integration.JSON_Pet
{
  CREATE Integration.PetResponse {
    PetId = id,
    Name = name,
    Status = status
  }
};

-- Step 4: Use in a microflow with IMPORT FROM MAPPING
-- $PetResponse = IMPORT FROM MAPPING Integration.IMM_Pet($JsonContent);

Export Mapping (Entity → JSON)

Serialize entities back to JSON using an export mapping.

CREATE EXPORT MAPPING Integration.EMM_Pet
  WITH JSON STRUCTURE Integration.JSON_Pet
{
  Integration.PetResponse {
    id = PetId,
    name = Name,
    status = Status
  }
};

-- Use in a microflow with EXPORT TO MAPPING
-- $JsonOutput = EXPORT TO MAPPING Integration.EMM_Pet($PetResponse);

Nested Mappings with Associations

Map nested JSON objects to multiple entities linked by associations.

CREATE IMPORT MAPPING Integration.IMM_Order
  WITH JSON STRUCTURE Integration.JSON_Order
{
  CREATE Integration.OrderResponse {
    OrderId = orderId KEY,
    CREATE Integration.OrderResponse_CustomerInfo/Integration.CustomerInfo = customer {
      Email = email,
      Name = name
    },
    CREATE Integration.OrderResponse_OrderItem/Integration.OrderItem = items {
      Sku = sku,
      Quantity = quantity,
      Price = price
    }
  }
};

Consuming a REST API

If the API has an OpenAPI 3.x spec (JSON or YAML), generate the REST client in one command. A Swagger 2.0 spec is refused; convert it to OpenAPI 3 first (for example npx swagger2openapi swagger.json -o openapi.json) and import the converted file.

mdl 1;
-- From a local file (relative to the .mpr file)
CREATE OR MODIFY CONSUMED REST SERVICE CapitalModule.CapitalAPI (
  OpenAPI: 'specs/capital.json'
);

-- From a URL
CREATE OR MODIFY CONSUMED REST SERVICE PetStoreModule.PetStoreAPI (
  OpenAPI: 'https://petstore3.swagger.io/api/v3/openapi.json'
);

Operations, path/query parameters, request bodies, response types, resource groups (from tags), and Basic auth are all derived automatically from the spec.

JSON responses are imported as Response: JSON (a string). The import does not yet create entities for them: to get objects, create non-persistent entities and give the operation a Response: MAPPING Module.Entity { … }, as shown under Manual Definition below.

Preview without writing:

DESCRIBE CONTRACT OPERATION FROM OPENAPI 'specs/capital.json';

Manual Definition

Define a reusable REST client with typed operations using CREATE CONSUMED REST SERVICE. Each operation declares its method, path, optional parameters, headers, body, and response mapping.

-- Define a client for the orders API
CREATE CONSUMED REST SERVICE Integration.OrdersApi (
  BaseUrl: 'https://api.example.com/v1',
  Authentication: NONE
)
{
  OPERATION GetOrder (
    Method: GET,
    Path: '/orders/{id}',
    Parameters: ($id: String),
    Headers: ('Accept': 'application/json'),
    Timeout: 30,
    Response: JSON AS $Result
  )

  OPERATION CreateOrder (
    Method: POST,
    Path: '/orders',
    Headers: ('Content-Type': 'application/json'),
    Body: MAPPING Integration.OrderRequest {
      customerId = CustomerId,
      totalAmount = TotalAmount,
      notes = Notes,
    },
    Response: MAPPING Integration.OrderResponse {
      Id = id,
      Status = status,
      CreatedAt = createdAt,
    }
  )
};

A header value is a template, like the path: {Token} is the operation parameter Token, which the operation must declare (Parameters: ($Token: String), else CE7056). An authorization header is Headers: ('Authorization': 'Bearer {Token}'). The expression spelling 'Bearer ' + $Token is deprecated (MDL-DEPR711) and mxcli fmt --upgrade rewrites it; it used to store only Bearer .

Use CREATE OR MODIFY CONSUMED REST SERVICE to replace an existing client’s definition without dropping it first. It declares the whole operation list: an operation it does not list is removed, and the output names it. The statement below keeps GetOrder and removes CreateOrder:

CREATE OR MODIFY CONSUMED REST SERVICE Integration.OrdersApi (
  BaseUrl: 'https://api.example.com/v2',
  Authentication: BASIC (Username: 'apiuser', Password: 'secret')
)
{
  OPERATION GetOrder (
    Method: GET,
    Path: '/orders/{id}',
    Parameters: ($id: String),
    Headers: ('Accept': 'application/json'),
    Timeout: 60,
    Response: JSON AS $Result
  )
};

To add, change or remove one operation and leave the rest as stored — including operations made in Studio Pro, whose settings MDL cannot always express — use ALTER CONSUMED REST SERVICE. MODIFY OPERATION writes only the keys it lists:

ALTER CONSUMED REST SERVICE Integration.OrdersApi
  ADD OPERATION CancelOrder (
    Method: POST,
    Path: '/orders/{id}/cancel',
    Parameters: ($id: String),
    Response: NONE
  ),
  MODIFY OPERATION GetOrder (Timeout: 60),
  DROP OPERATION LegacyOrder,
  SET (BaseUrl: 'https://api.example.com/v2');

Body types: JSON FROM $var, TEMPLATE '...', MAPPING Entity { jsonField = Attr, ... } Response types: JSON, STRING, FILE, STATUS (each optionally AS $var — the variable is not stored; the call site binds it), NONE, MAPPING Entity { Attr = jsonField, ... } Authentication: NONE, BASIC (Username: '...', Password: '...')

Publishing a REST API

Create a published REST service with CRUD operations backed by microflows.

/** Order management API for the shop front end. */
CREATE PUBLISHED REST SERVICE Module.OrderAPI (
  Path: 'rest/orders/v1',
  Version: '1.0.0',
  ServiceName: 'Order API'
)
{
  RESOURCE 'orders' {
    GET '' MICROFLOW Module.PRS_GetAllOrders;
    GET '{id}' MICROFLOW Module.PRS_GetOrderById;
    POST '' MICROFLOW Module.PRS_CreateOrder;
    PUT '{id}' MICROFLOW Module.PRS_UpdateOrder;
    DELETE '{id}' MICROFLOW Module.PRS_DeleteOrder;
  }
};

Documentation: the /** … */ doc comment above CREATE is the service’s documentation — what lint QUAL002 asks for. On CREATE OR MODIFY no comment keeps the stored text and /** */ clears it. The OpenAPI description consumers read (PublicDocumentation) has no MDL spelling and is kept as stored.

Operation paths: Use empty string '' for the root, '{paramName}' for path parameters. Do NOT start or end with /. Path parameters must match a microflow parameter name exactly (case-sensitive) — '{id}' binds the microflow’s $id, whatever its type.

Operation parameters come from the microflow, the way Studio Pro derives them. Create the microflow before the service:

Microflow parameterOperation parameter
named in the path ('{id}')a path parameter
an object or a listthe body
System.HttpRequest, System.HttpResponsenone — they are the request and the response
anything elsea query parameter

Each gets the microflow parameter’s type. A header parameter, a renamed parameter or a description set in Studio Pro has no MDL spelling: describe notes it in a comment, and CREATE OR MODIFY / ALTER on that project keep it.

Mappings and commit: a body that is not a file document needs an import mapping, and a microflow returning an object or a list needs an export mapping:

mdl 1;
CREATE OR MODIFY PUBLISHED REST SERVICE Module.OrderAPI (
  Path: 'rest/orders/v1',
  Version: '1.0.0',
  ServiceName: 'Order API'
)
{
  RESOURCE 'orders' {
    -- COMMIT: Yes (the default without the clause) | YesWithoutEvents | No
    POST '' MICROFLOW Module.PRS_CreateOrder
      IMPORT MAPPING Module.IMM_Order EXPORT MAPPING Module.EMM_Order
      COMMIT YesWithoutEvents;
  }
};

Authentication

Authentication sets how a caller authenticates — Studio Pro’s Requires authentication and its methods:

mdl 1;
CREATE OR MODIFY PUBLISHED REST SERVICE Module.OrderAPI (
  Path: 'rest/orders/v1',
  Version: '1.0.0',
  ServiceName: 'Order API',
  Authentication: (basic, session, microflow Module.PRS_Authenticate)
)
{
  RESOURCE 'orders' {
    GET '' MICROFLOW Module.PRS_GetAllOrders;
  }
};
GRANT ACCESS ON PUBLISHED REST SERVICE Module.OrderAPI TO Module.User;

ALTER PUBLISHED REST SERVICE Module.OrderAPI SET (Authentication: (session));
ALTER PUBLISHED REST SERVICE Module.OrderAPI SET (Authentication: none);
MethodStudio Pro
basicUsername and password
sessionActive session
microflow Module.NameCustom, with that authentication microflow
none (instead of a list)Requires authentication: No
  • The methods are stored in the order written, as Studio Pro stores them in the order they were ticked; describe prints the stored order.
  • Left out, CREATE OR MODIFY and ALTER keep the service’s stored setting. A new service without it requires no authentication. describe does not print none.
  • The authentication microflow returns System.User — empty means “not authenticated”. An object parameter can only be a System.HttpRequest or a System.HttpResponse, matched by type; any other parameter is an authentication parameter of the service (below). exec and check --references refuse anything else as MDL-REST04 (Mendix: CE0334, CE0336).
  • With authentication on, the service needs at least one allowed module role (GRANT ACCESS …), or mx check reports CE0338. Custom authentication also needs app security on (CE6600). Roles without authentication are accepted.

Authentication parameters

A custom-authentication microflow can read values of the request as its own parameters — a token in a header, a tenant in the query string. Each one is bound in an optional list after the microflow (Studio Pro: the service’s Parameters, Mendix 7.17+):

mdl 1;
CREATE OR MODIFY MICROFLOW Module.PRS_Authenticate (
  $HttpRequest: System.HttpRequest, $Token: String, $Tenant: Integer, $Lang: String
)
RETURNS System.User AS $User
BEGIN
  RETRIEVE $User FROM System.User WHERE [Name = $Token] FIRST;
  RETURN $User;
END;

CREATE OR MODIFY PUBLISHED REST SERVICE Module.OrderAPI (
  Path: 'rest/orders/v1',
  Version: '1.0.0',
  ServiceName: 'Order API',
  Authentication: (
    basic,
    microflow Module.PRS_Authenticate (
      $Token: header 'X-Api-Token',
      $Tenant: query,
      $Lang: query 'lang' description 'UI language, defaults to en'
    )
  )
)
{
  RESOURCE 'orders' {
    GET '' MICROFLOW Module.PRS_GetAllOrders;
  }
};
Entry partMeaning
$TokenThe authentication microflow’s parameter the value is passed to
query | headerWhere the value comes from; nothing else is accepted
'X-Api-Token'The HTTP name, when it is not the parameter’s own name
description '…'Optional
  • The data type is never written: it is the microflow parameter’s.
  • A parameter not listed is a query parameter under its own name, so a microflow with $Token: String works with microflow Module.PRS_Authenticate alone. System.HttpRequest / System.HttpResponse are never listed. Without the service parameter Mendix reports CE0336 (“The microflow parameter (‘Token’) is not present in the custom authentication parameter list”).
  • MDL-REST04 refuses an entry naming a parameter the microflow does not have, a request or response parameter, a kind other than query / header, and a parameter listed twice.
  • The list follows the microflow: listed entries in the order written, then the unlisted ones in the microflow’s order. Methods without microflow clear it. Left out with the rest of Authentication, the stored parameters are kept.
  • describe prints the list only when the stored parameters are not what microflow M.F alone would write — a header, a renamed or described parameter, or another order.

Multiple Resources

CREATE PUBLISHED REST SERVICE Module.CrmAPI (
  Path: 'rest/crm/v1',
  Version: '1.0.0',
  ServiceName: 'CRM API'
)
{
  RESOURCE 'orders' {
    GET '' MICROFLOW Module.PRS_GetOrders;
  }
  RESOURCE 'customers' {
    GET '' MICROFLOW Module.PRS_GetCustomers;
  }
  RESOURCE 'orders/{orderId}/items' {
    GET '' MICROFLOW Module.PRS_GetOrderItems;
  }
};

Update or Remove

-- Replace with new version
CREATE OR REPLACE PUBLISHED REST SERVICE Module.OrderAPI (
  Path: 'rest/orders/v2',
  Version: '2.0.0',
  ServiceName: 'Order API v2'
) { ... };

-- Remove entirely
DROP PUBLISHED REST SERVICE Module.OrderAPI;

Data Transformers (JSLT)

Data Transformers apply transformation steps (JSLT or XSLT) to JSON or XML payloads. Useful for reshaping API responses before import mapping, or normalising data from third-party sources. Requires Mendix 11.9+.

mdl 1;
-- Create a transformer that extracts key fields from a weather API response.
-- The SOURCE JSON defines a sample payload used for schema inference and testing.
CREATE DATA TRANSFORMER Integration.WeatherTransform
SOURCE JSON '{
  "latitude": 51.9,
  "longitude": 4.5,
  "timezone": "Europe/Amsterdam",
  "current": {
    "time": "2024-01-15T14:00",
    "temperature_2m": 12.8,
    "wind_speed_10m": 18.3,
    "weather_code": 3
  }
}'
{
  JSLT $$
{
  "lat":        .latitude,
  "lon":        .longitude,
  "timezone":   .timezone,
  "temp":       .current.temperature_2m,
  "wind_speed": .current.wind_speed_10m,
  "code":       .current.weather_code
}
  $$;
};

-- List all data transformers in the Integration module
LIST DATA TRANSFORMERS IN Integration;

-- Inspect a transformer (outputs a re-executable CREATE statement)
DESCRIBE DATA TRANSFORMER Integration.WeatherTransform;

-- Remove a transformer
DROP DATA TRANSFORMER Integration.WeatherTransform;

Notes:

  • Steps execute in order; the output of each step feeds the next.
  • JSLT '...' for short single-line expressions; JSLT $$ ... $$ for multi-line.
  • XSLT $$ ... $$ is also supported for XML-to-XML transformations.
  • Requires Mendix 11.9+. Use LIST FEATURES to confirm support before using.

Data Import Pipeline

Connect to an external database, explore its schema, import data into Mendix, and generate database connectors – all from the mxcli REPL.

Connect and Explore

-- Connect to the legacy database
SQL CONNECT postgres 'host=legacy-db port=5432 dbname=crm user=readonly' AS legacy;

-- Discover the schema
SQL legacy SHOW TABLES;
SQL legacy DESCRIBE customers;
SQL legacy DESCRIBE orders;

-- Preview the data
SQL legacy SELECT * FROM customers LIMIT 10;

Import Data

The IMPORT FROM statement reads from the external database and inserts into the Mendix application’s PostgreSQL database with proper Mendix ID generation:

-- Import customers
IMPORT FROM legacy
  QUERY 'SELECT name, email, phone, active FROM customers'
  INTO CRM.Customer
  MAP (name AS Name, email AS Email, phone AS Phone, active AS IsActive)
  BATCH 500;

-- Import orders with association linking
-- The LINK clause looks up Customer by Email and creates the association
IMPORT FROM legacy
  QUERY 'SELECT order_number, order_date, total, customer_email FROM orders'
  INTO Sales.Order
  MAP (order_number AS OrderNumber, order_date AS OrderDate, total AS TotalAmount)
  LINK (customer_email TO Sales.Order_Customer ON Email)
  BATCH 500;

Generate Database Connectors

For ongoing integration (querying the external database from microflows at runtime), generate Database Connector entities and queries:

-- Auto-generate non-persistent entities and query microflows
SQL legacy GENERATE CONNECTOR INTO Integration
  TABLES (customers, orders, products);

This generates:

  • Constants for JDBC connection string, username, and password
  • Non-persistent entities with mapped attributes (SQL types to Mendix types)
  • A DATABASE CONNECTION definition with query microflows

The output is valid MDL that can be reviewed before execution. Add the EXEC flag to execute immediately:

SQL legacy GENERATE CONNECTOR INTO Integration
  TABLES (customers, orders)
  EXEC;

Execute Queries from Microflows

Once a database connection is defined, microflows can execute queries at runtime:

mdl 1;
-- Non-persistent entity for query results
CREATE NON-PERSISTENT ENTITY Integration.Customer (
  /** Customer name from external system */
  Name: String(200),
  /** Email address */
  Email: String(200),
  /** Account balance */
  Balance: Decimal
);

-- Database connection with parameterized query
CREATE DATABASE CONNECTION Integration.LegacyDatabase (
  Type: 'PostgreSQL',
  ConnectionString: @Integration.LegacyDatabase_DBSource,
  Username: @Integration.LegacyDatabase_DBUsername,
  Password: @Integration.LegacyDatabase_DBPassword
) {
  QUERY SearchCustomers (
    Sql: 'SELECT name, email, balance FROM customers WHERE name ILIKE {search}',
    Parameters: ( search: String DEFAULT '%' ),
    Returns: Integration.Customer,
    Map: (Name = name, Email = email, Balance = balance)
  )
};

-- Microflow that executes the query
CREATE MICROFLOW Integration.SearchLegacyCustomers ($SearchTerm: String)
RETURNS List of Integration.Customer AS $Results
BEGIN
  $Results = EXECUTE DATABASE QUERY Integration.LegacyDatabase.SearchCustomers
    (search = '%' + $SearchTerm + '%');
  RETURN $Results;
END;

Master-Detail Page

A single page with a list on the left and a detail form on the right. Selecting an item in the list updates the form via the SELECTION data source binding – no microflow needed.

CREATE PAGE CRM.Customer_MasterDetail (
  Title: 'Customers',
  Layout: Atlas_Core.Atlas_Default
) {
  LAYOUTGRID mainGrid {
    ROW {
      -- Master list (left panel)
      COLUMN (DesktopWidth: 4) {
        DYNAMICTEXT heading (Content: 'Customers', RenderMode: H3)
        GALLERY customerList (DataSource: DATABASE CRM.Customer, Selection: Single) {
          TEMPLATE {
            DYNAMICTEXT name (
              Content: '{1}',
              ContentParams: ({1} = Name),
              RenderMode: H4
            )
            DYNAMICTEXT email (
              Content: '{1}',
              ContentParams: ({1} = Email)
            )
          }
        }
      }

      -- Detail form (right panel, bound to gallery selection)
      COLUMN (DesktopWidth: 8) {
        DYNAMICTEXT detailHeading (Content: 'Details', RenderMode: H3)
        DATAVIEW customerDetail (DataSource: SELECTION customerList) {
          TEXTBOX txtName (Label: 'Name', Attribute: Name)
          TEXTBOX txtEmail (Label: 'Email', Attribute: Email)
          TEXTBOX txtPhone (Label: 'Phone', Attribute: Phone)
          TEXTAREA txtNotes (Label: 'Notes', Attribute: Notes)
          FOOTER {
            ACTIONBUTTON btnSave (
              Caption: 'Save',
              Action: SAVE CHANGES,
              ButtonStyle: Success
            )
            ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
          }
        }
      }
    }
  }
};

The key pattern is DataSource: SELECTION customerList – the data view automatically displays whichever item is selected in the gallery. No event handlers, no microflow calls, no state management.

Modifying Existing Pages

ALTER PAGE modifies widgets in place without recreating the entire page. This is useful for incremental changes, maintenance, and agent-driven iteration.

Change a Button

ALTER PAGE CRM.Customer_Edit {
  SET (Caption: 'Save & Close', ButtonStyle: Success) ON btnSave
};

Add a Field After an Existing One

ALTER PAGE CRM.Customer_Edit {
  INSERT AFTER txtEmail {
    TEXTBOX txtPhone (Label: 'Phone', Attribute: Phone)
  }
};

Add a Widget Into a Container

Use INSERT INTO to append a widget as a container’s last child — the only way to fill an empty container.

ALTER PAGE CRM.Customer_Overview {
  INSERT INTO ctnToolbar {
    ACTIONBUTTON btnNew (Caption: 'New Customer', Action: NOTHING, ButtonStyle: Primary)
  }
};

Remove Widgets

ALTER PAGE CRM.Customer_Edit {
  DROP txtLegacyField, lblOldNote
};
ALTER PAGE CRM.Customer_Edit {
  REPLACE dvMain.footer WITH {
    FOOTER {
      ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Success)
      ACTIONBUTTON btnDelete (Caption: 'Delete', Action: DELETE, ButtonStyle: Danger)
      ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
    }
  }
};

Multiple Changes at Once

ALTER PAGE CRM.Customer_Edit {
  SET (Title: 'Edit Customer Details');
  SET (Label: 'Email Address') ON txtEmail;
  INSERT AFTER txtPhone {
    TEXTBOX txtWebsite (Label: 'Website', Attribute: Website)
  };
  DROP lblInternalRef
};

Add a Page Variable

ALTER PAGE CRM.ProductOverview {
  ADD Variables $showStockColumn: Boolean = if (3 < 4) then true else false
};

Add a Page Parameter

Add a parameter to an existing page without rewriting it. The page has a URL, so the new parameter gets a segment in the same statement:

ALTER PAGE CRM.Customer_Edit {
  SET (Url: 'customer-edit/{Customer}/{Order}');
  ADD Parameters $Order: CRM.Order
};

Switch Page Layout

Change a page’s layout without losing any widgets:

-- Switch from TopBar to Default layout (auto-maps by placeholder name)
ALTER PAGE CRM.Customer_Edit {
  SET Layout = Atlas_Core.Atlas_Default
};

When the new layout has different placeholder names, use MAP:

ALTER PAGE CRM.Customer_Edit {
  SET Layout = Atlas_Core.Atlas_SideBar MAP (Main AS Content, Extra AS Sidebar)
};

Modify DataGrid Columns

Target columns using dotted notation gridName.columnName:

mdl 1;
-- Add a column
ALTER PAGE CRM.Customer_List {
  INSERT AFTER dgCustomers.Email {
    COLUMN Phone (Attribute: Phone, Caption: 'Phone')
  }
};

-- Remove a column
ALTER PAGE CRM.Customer_List {
  DROP dgCustomers.OldNotes
};

-- Rename a column header
ALTER PAGE CRM.Customer_List {
  SET (Caption: 'E-mail Address') ON dgCustomers.Email
};

Use DESCRIBE PAGE CRM.Customer_List to discover column names.

Works on Snippets Too

ALTER SNIPPET CRM.NavigationMenu {
  SET (Caption: 'Dashboard') ON btnHome;
  INSERT AFTER btnHome {
    ACTIONBUTTON btnReports (
      Caption: 'Reports',
      Action: SHOW PAGE CRM.Reports_Overview
    )
  }
};

Validation Pattern

Mendix uses a two-microflow pattern for form validation: a validation microflow checks fields and returns feedback, and an action microflow calls it before saving.

The Validation Microflow

VALIDATION FEEDBACK attaches error messages to specific fields. The form highlights the field and displays the message.

CREATE MICROFLOW Sales.VAL_Order ($Order: Sales.Order)
RETURNS Boolean AS $IsValid
BEGIN
  DECLARE $IsValid Boolean = true;

  -- Required field
  IF $Order/OrderNumber = empty OR trim($Order/OrderNumber) = '' THEN
    SET $IsValid = false;
    VALIDATION FEEDBACK $Order/OrderNumber MESSAGE 'Order number is required';
  END IF;

  -- Date must be in the future
  IF $Order/DeliveryDate != empty AND $Order/DeliveryDate < [%CurrentDateTime%] THEN
    SET $IsValid = false;
    VALIDATION FEEDBACK $Order/DeliveryDate MESSAGE 'Delivery date must be in the future';
  END IF;

  -- Numeric range
  IF $Order/Quantity <= 0 THEN
    SET $IsValid = false;
    VALIDATION FEEDBACK $Order/Quantity MESSAGE 'Quantity must be greater than zero';
  END IF;

  IF $Order/Quantity > 10000 THEN
    SET $IsValid = false;
    VALIDATION FEEDBACK $Order/Quantity MESSAGE 'Maximum quantity is 10,000';
  END IF;

  -- Cross-field validation
  IF $Order/DiscountPercent > 0 AND $Order/ApprovedBy = empty THEN
    SET $IsValid = false;
    VALIDATION FEEDBACK $Order/ApprovedBy MESSAGE 'Discounted orders require approval';
  END IF;

  RETURN $IsValid;
END;

The Action Microflow

The action microflow calls validation, and only saves if it passes:

CREATE MICROFLOW Sales.ACT_Order_Save ($Order: Sales.Order)
RETURNS Boolean AS $IsValid
BEGIN
  $IsValid = CALL MICROFLOW Sales.VAL_Order(param = $Order);

  IF $IsValid THEN
    COMMIT $Order;
    CLOSE PAGE;
  END IF;

  RETURN $IsValid;
END;

Wiring It Up

The page’s Save button calls the action microflow (not the validation microflow directly):

CREATE PAGE Sales.Order_Edit (
  Params: ( $Order: Sales.Order ),
  Title: 'Order',
  Layout: Atlas_Core.PopupLayout
) {
  LAYOUTGRID mainGrid {
    ROW {
      COLUMN (DesktopWidth: AutoFill) {
        DATAVIEW dv (DataSource: $Order) {
          TEXTBOX txtOrderNumber (Label: 'Order Number', Attribute: OrderNumber)
          DATEPICKER dpDelivery (Label: 'Delivery Date', Attribute: DeliveryDate)
          TEXTBOX txtQuantity (Label: 'Quantity', Attribute: Quantity)
          TEXTBOX txtDiscount (Label: 'Discount %', Attribute: DiscountPercent)
          FOOTER {
            ACTIONBUTTON btnSave (
              Caption: 'Save',
              Action: CALL MICROFLOW Sales.ACT_Order_Save,
              ButtonStyle: Success
            )
            ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
          }
        }
      }
    }
  }
};

Role-Based Security

A complete security setup: module roles, entity access with XPath row-level constraints, document access, user roles, and demo users.

Module Roles

Module roles define what actions are available within a module:

mdl 1;
CREATE MODULE ROLE Sales.Viewer DESCRIPTION 'Read-only access to sales data';
CREATE MODULE ROLE Sales.User DESCRIPTION 'Can create and edit orders';
CREATE MODULE ROLE Sales.Admin DESCRIPTION 'Full access including delete';

Entity Access

GRANT controls which CRUD operations a role can perform. XPath constraints in WHERE filter which rows are visible:

mdl 1;
-- Admin: full access to all customers
GRANT CREATE, DELETE, READ *, WRITE * ON ENTITY Sales.Customer TO Sales.Admin;

-- User: can create and edit, but only active customers
GRANT CREATE, READ *, WRITE * ON ENTITY Sales.Customer TO Sales.User
  WHERE [IsActive = true];

-- Viewer: read-only, active customers only
GRANT READ * ON ENTITY Sales.Customer TO Sales.Viewer
  WHERE [IsActive = true];

-- Orders: users can only see their own (via owner token)
GRANT CREATE, READ *, WRITE * ON ENTITY Sales.Order TO Sales.User
  WHERE [System.owner = '[%CurrentUser%]'];

-- Admin sees all orders
GRANT CREATE, DELETE, READ *, WRITE * ON ENTITY Sales.Order TO Sales.Admin;

Microflow and Page Access

mdl 1;
-- Microflow access
GRANT EXECUTE ON MICROFLOW Sales.ACT_Order_Save TO Sales.User;
GRANT EXECUTE ON MICROFLOW Sales.ACT_Order_Delete TO Sales.Admin;

-- Page access
GRANT VIEW ON PAGE Sales.Customer_Overview TO Sales.Viewer;
GRANT VIEW ON PAGE Sales.Customer_Overview TO Sales.User;
GRANT VIEW ON PAGE Sales.Order_Edit TO Sales.User;
GRANT VIEW ON PAGE Sales.Admin_Dashboard TO Sales.Admin;

User Roles

User roles combine module roles from different modules into a single assignable role:

mdl 1;
CREATE OR MODIFY USER ROLE SalesViewer ( ModuleRoles: (System.User, Sales.Viewer) );
CREATE OR MODIFY USER ROLE SalesRep ( ModuleRoles: (System.User, Sales.User) );
CREATE OR MODIFY USER ROLE SalesManager ( ModuleRoles: (System.User, Sales.Admin), ManageAllRoles: true );

Demo Users

Demo users are created for testing and development:

mdl 1;
CREATE OR MODIFY DEMO USER 'viewer' ( Password: 'Password1!', UserRoles: (SalesViewer) );
CREATE OR MODIFY DEMO USER 'sales_rep' ( Password: 'Password1!', UserRoles: (SalesRep) );
CREATE OR MODIFY DEMO USER 'manager' ( Password: 'Password1!', UserRoles: (SalesManager) );

-- Enable demo users in project security
ALTER APP SECURITY ( EnableDemoUsers: TRUE );

Additive Grants

GRANT merges with existing access — it never removes permissions:

-- Viewer already has READ (Name, Email)
GRANT READ (Phone) ON ENTITY Sales.Customer TO Sales.Viewer;
-- Result: READ (Name, Email, Phone)

Revoking Access

mdl 1;
-- Remove all access for a role
REVOKE ALL ON ENTITY Sales.Customer FROM Sales.Viewer;

-- Partial revoke: remove read on a specific attribute
REVOKE READ (Phone) ON ENTITY Sales.Customer FROM Sales.User;

-- Partial revoke: downgrade write to read-only
REVOKE WRITE (Email) ON ENTITY Sales.Customer FROM Sales.User;

-- Remove microflow access
REVOKE EXECUTE ON MICROFLOW Sales.ACT_Order_Delete FROM Sales.User;

View Entities

View entities are backed by an OQL query. They appear in the domain model but have no database table (and no database view either) – their rows are computed from other entities via aggregation and joins, evaluated by the runtime per query. “Read-only” is a common but imprecise shorthand: a view row has no storage and commit does not write it back, yet it is editable in memory (it behaves like a non-persistent object), so a form can bind to one and write changes back through the source entity. See View Entity for the full model and the CE6770 type-match gotcha.

Sales Summary by Category

mdl 1;
-- Source entities
CREATE PERSISTENT ENTITY Reports.ProductCategory (
  /** Category display name */
  CategoryName: String(200) NOT NULL
);

CREATE PERSISTENT ENTITY Reports.SaleTransaction (
  /** Transaction amount */
  Amount: Decimal NOT NULL,
  /** Date of the sale */
  SaleDate: DateTime NOT NULL
);

CREATE ASSOCIATION Reports.SaleTransaction_ProductCategory
  FROM Reports.ProductCategory TO Reports.SaleTransaction
  TYPE Reference OWNER Default;

-- View entity: aggregates sales by category
CREATE VIEW ENTITY Reports.SalesTotalByCategory (
  CategoryName: String(200),
  TotalAmount: Decimal,
  TransactionCount: Integer
) AS (
  SELECT
    c.CategoryName AS CategoryName,
    sum(s.Amount) AS TotalAmount,
    count(s.ID) AS TransactionCount
  FROM Reports.SaleTransaction AS s
  INNER JOIN s/Reports.SaleTransaction_ProductCategory/Reports.ProductCategory AS c
  GROUP BY c.CategoryName
);

Querying a View Entity in a Microflow

View entities can be retrieved like any other entity, including with WHERE filters:

CREATE MICROFLOW Reports.GetSalesTotalForCategory (
  $Category: Reports.ProductCategory
)
RETURNS Decimal AS $TotalAmount
BEGIN
  DECLARE $TotalAmount Decimal = 0;

  RETRIEVE $Summary FROM Reports.SalesTotalByCategory
    WHERE CategoryName = $Category/CategoryName
    FIRST;

  IF $Summary != empty THEN
    SET $TotalAmount = $Summary/TotalAmount;
  END IF;

  RETURN $TotalAmount;
END;

Displaying in a Page

View entities work with data grids and list views like persistent entities:

CREATE PAGE Reports.SalesByCategory_Overview (
  Title: 'Sales by Category',
  Layout: Atlas_Core.Atlas_Default
) {
  DATAGRID dgSales (DataSource: DATABASE Reports.SalesTotalByCategory) {
    COLUMN (Attribute: CategoryName, Caption: 'Category')
    COLUMN (Attribute: TotalAmount, Caption: 'Total Sales')
    COLUMN (Attribute: TransactionCount, Caption: 'Transactions')
  }
};

Migration Guide

mxcli and MDL enable AI-assisted migration of existing applications to the Mendix platform. An AI coding agent (Claude Code, GitHub Copilot, OpenCode, Cursor, Windsurf) investigates the source application, maps its elements to Mendix concepts, generates MDL scripts, and validates the result – all from the command line.

This part covers the five-phase migration workflow and the skills that support each phase.

Why mxcli for Migration?

Traditional migrations require deep Mendix expertise and manual work in Studio Pro. With mxcli:

  • AI agents do the heavy lifting – the agent reads source code, proposes a transformation plan, and generates MDL scripts
  • Skills provide guardrails – platform-specific migration skills guide the agent with correct mappings, naming conventions, and patterns
  • Validation is automated – mxcli check, lint, docker check, and test catch errors before anyone opens Studio Pro
  • The process is repeatable – MDL scripts can be version-controlled, reviewed, and re-run

The Five Phases

  Phase 1           Phase 2           Phase 3           Phase 4         Phase 5
 ┌──────────┐    ┌───────────┐   ┌──────────────┐   ┌──────────┐   ┌──────────┐
 │  Assess  │--> │  Propose  │-->│   Generate   │-->│   Test   │-->│  Finish  │
 │  Source  │    │ Transform │   │  Mendix App  │   │  & Lint  │   │ in Studio│
 └──────────┘    └───────────┘   └──────────────┘   └──────────┘   │   Pro    │
                                                                   └──────────┘
PhaseWhat HappensKey Skills
AssessmentInvestigate source application, produce inventoryassess-migration, platform-specific skills
TransformationMap source elements to Mendix, plan module structureAssessment output as input
GenerationGenerate domain model, microflows, pages, securitygenerate-domain-model, write-microflows, create-page, manage-security
Data MigrationImport data from source databasesdemo-data, database-connections
Validation and HandoffTest, lint, and hand off to Studio Procheck-syntax, assess-quality

Phase 1: Assessment

The first step is a thorough investigation of the source application. The AI agent examines the codebase and produces a structured migration inventory.

The assess-migration Skill

The assess-migration skill provides a 6-step investigation framework that works with any technology stack – Java, .NET, Python, Node.js, PHP, Ruby, or any other platform. It guides the agent through:

  1. Technology stack – build files, frameworks, runtime versions
  2. Data model – ORM entities, database schema, relationships
  3. Business logic – service classes, stored procedures, validation rules
  4. User interface – pages, templates, navigation structure
  5. Integrations – REST/SOAP clients, message queues, file feeds
  6. Security – authentication, authorization, role definitions

What to Extract

CategoryWhat to DocumentWhere to Look
Data modelEntities, attributes, relationships, constraintsJPA entities, Django models, EF DbContext, DB schema
Business logicValidation rules, calculations, workflowsService classes, stored procedures, triggers
Pages / UIScreens, forms, dashboards, navigationReact components, Razor views, JSP templates
IntegrationsAPIs consumed/exposed, file feeds, queuesREST clients, SOAP services, Kafka topics
SecurityAuthentication, roles, data access rulesSpring Security, RBAC policies, row-level security
Scheduled jobsBackground tasks, timers, batch processingCron jobs, Quartz schedulers, Celery tasks

Platform-Specific Skills

For common source platforms, dedicated skills provide deeper guidance with precise element mappings:

Source PlatformSkillKey Mappings
Oracle Formsmigrate-oracle-formsForm -> Page, Block -> Snippet, PL/SQL -> Microflow, LOV -> Enumeration
K2 / Nintexmigrate-k2-nintexSmartForm -> Page, SmartObject -> Entity, Workflow -> Microflow chain
Any stackassess-migrationGeneric framework for any technology

Assessment Output

The assessment produces a structured report with:

  • Executive summary – technology stack, application size, complexity rating
  • Categorized inventory – counts and details for each category
  • Mendix mapping – how each source element maps to Mendix concepts
  • Migration risks – complex stored procedures, custom UI components, real-time integrations
  • Recommended phases – suggested order of migration work

Example: Starting an Assessment

Point the AI agent at the source codebase and ask it to assess for migration:

Assess the application in /path/to/source for migration to Mendix.
Use the assess-migration skill.

The agent will investigate build files, ORM models, service classes, UI templates, security configuration, and API clients. The resulting assessment report becomes the input for Phase 2.

Phase 2: Transformation Plan

With the assessment complete, the agent creates a detailed transformation plan that maps every source element to its Mendix equivalent.

Module Structure

Plan the Mendix module structure. A common pattern is to consolidate many small source modules into fewer Mendix modules:

Source Application           Mendix
─────────────────           ─────────
CRM_Core, CRM_UI    ──▶    CRM
Cases_Core, Cases_UI ──▶    Cases
Auth_Core, Auth_SSO  ──▶    Administration
Shared_Utils         ──▶    Commons

Transformation Mapping

For each element in the assessment, the agent documents the target Mendix implementation:

Source ElementSource LocationMendix TargetMDL Statement
Customer tableschema.sqlCRM.Customer entityCREATE PERSISTENT ENTITY
OrderStatus enumenums.pySales.OrderStatus enumerationCREATE ENUMERATION
calculateTotal()OrderService.javaSales.ACT_Order_CalculateTotal microflowCREATE MICROFLOW
Customer list pagecustomers.htmlCRM.Customer_Overview pageCREATE PAGE
Admin rolesecurity.xmlAdministration.Admin module roleGRANT statements

Prioritization

Order the migration work to maximize early value and minimize dependency issues:

  1. Enumerations – no dependencies, used by entities
  2. Domain model – entities, attributes, associations
  3. Security – module roles, user roles, access rules
  4. Core business logic – validation microflows, calculation microflows
  5. Pages – overview pages, edit forms, dashboards
  6. Integrations – REST clients, OData services, file handling
  7. Navigation – menu structure, home pages
  8. Advanced features – scheduled events, workflows, business events

This order ensures that each layer can reference the elements created before it.

Phase 3: Generation

With the transformation plan in hand, the agent generates the Mendix application using MDL scripts. Skills guide the agent toward correct, idiomatic MDL.

Key Skills

SkillPurpose
generate-domain-modelEntity, association, and enumeration syntax with naming conventions
write-microflowsMicroflow syntax, 60+ activity types, common patterns
create-pagePage and widget syntax for 50+ widget types
overview-pagesCRUD page patterns (list + detail)
master-detail-pagesMaster-detail page layouts
manage-securityModule roles, user roles, GRANT/REVOKE, demo users
manage-navigationNavigation profiles, menu items, home pages
organize-projectFolder structure, MOVE command, project conventions

Generation Workflow

# 1. Create a new Mendix project in Studio Pro (or use an existing one)
# 2. Execute MDL scripts in dependency order
mxcli exec domain-model.mdl -p app.mpr
mxcli exec microflows.mdl -p app.mpr
mxcli exec pages.mdl -p app.mpr
mxcli exec security.mdl -p app.mpr

# Or work interactively in the REPL
mxcli -p app.mpr

Example: Domain Model

mdl 1;
-- Enumerations first (referenced by entities)
CREATE ENUMERATION Sales.OrderStatus (
  Draft 'Draft',
  Pending 'Pending',
  Confirmed 'Confirmed',
  Shipped 'Shipped',
  Delivered 'Delivered',
  Cancelled 'Cancelled'
);

-- Entities
/** Customer master data */
@Position(100, 100)
CREATE PERSISTENT ENTITY CRM.Customer (
  Name: String(200) NOT NULL ERROR MESSAGE 'Customer name is required',
  Email: String(200) UNIQUE ERROR MESSAGE 'Email already exists',
  Phone: String(50),
  IsActive: Boolean DEFAULT TRUE
)
INDEX (Name)
INDEX (Email);

/** Sales order */
@Position(300, 100)
CREATE PERSISTENT ENTITY Sales.Order (
  OrderNumber: String(50) NOT NULL UNIQUE,
  OrderDate: DateTime NOT NULL,
  TotalAmount: Decimal DEFAULT 0,
  Status: Enumeration(Sales.OrderStatus) DEFAULT 'Draft'
)
INDEX (OrderNumber)
INDEX (OrderDate DESC);

-- Associations
CREATE ASSOCIATION Sales.Order_Customer
  FROM Sales.Order TO CRM.Customer
  TYPE Reference OWNER Default;

Example: Microflow

CREATE MICROFLOW Sales.ACT_Order_CalculateTotal
BEGIN
  DECLARE $Order Sales.Order;
  RETRIEVE $Lines FROM Sales.OrderLine
    WHERE [Sales.OrderLine_Order = $Order];
  DECLARE $Total Decimal = 0;
  LOOP $Line IN $Lines
  BEGIN
    SET $Total = $Total + $Line/Price * $Line/Quantity;
  END;
  CHANGE $Order (TotalAmount = $Total);
  COMMIT $Order;
END;
/

Example: Page

CREATE PAGE CRM.Customer_Overview (
  Title: 'Customers',
  Layout: Atlas_Core.Atlas_Default
) {
  DATAGRID dgCustomers (DataSource: DATABASE CRM.Customer, Selection: Single) {
    COLUMN (Attribute: Name, Caption: 'Name') { TEXTFILTER fName }
    COLUMN (Attribute: Email, Caption: 'Email') { TEXTFILTER fEmail }
    COLUMN (Attribute: Phone, Caption: 'Phone')
    COLUMN (Attribute: IsActive, Caption: 'Active')
    CONTROLBAR {
      ACTIONBUTTON btnNew (Caption: 'New', Action: SHOW PAGE CRM.Customer_NewEdit, ButtonStyle: Primary)
    }
  }
};

Validation Between Steps

Validate after each script to catch errors early:

# Syntax check (fast, no project used)
mxcli check domain-model.mdl --no-project

# Reference validation (checks entity/microflow names exist)
mxcli check pages.mdl -p app.mpr --references

Data Migration

mxcli can connect directly to external databases to explore schemas, import data, and generate database connector code. This is useful for migrating reference data, seeding test data, or setting up ongoing integrations with legacy systems.

Key Skills

SkillPurpose
demo-dataMendix ID system, association storage, direct PostgreSQL insertion
database-connectionsExternal database connectivity from microflows (Database Connector module)
patterns-data-processingLoop patterns, batch processing, list operations

Connect to the Source Database

mxcli supports PostgreSQL, Oracle, and SQL Server:

-- Connect to the legacy database
SQL CONNECT postgres 'host=legacy-db port=5432 dbname=crm user=readonly password=...' AS legacy;

-- Explore the schema
SQL legacy SHOW TABLES;
SQL legacy DESCRIBE customers;

Import Data

The IMPORT FROM statement reads from the source database and inserts into the Mendix application’s PostgreSQL database:

-- Import customers
IMPORT FROM legacy
  QUERY 'SELECT name, email, phone, active FROM customers'
  INTO CRM.Customer
  MAP (name AS Name, email AS Email, phone AS Phone, active AS IsActive)
  BATCH 500;

-- Import with association linking
IMPORT FROM legacy
  QUERY 'SELECT order_number, order_date, total, customer_email FROM orders'
  INTO Sales.Order
  MAP (order_number AS OrderNumber, order_date AS OrderDate, total AS TotalAmount)
  LINK (customer_email TO Sales.Order_Customer ON Email)
  BATCH 500;

The import pipeline handles:

  • Mendix ID generation – creates valid 64-bit object IDs
  • Batch insertion – respects PostgreSQL parameter limits
  • Association linking – looks up related entities by attribute value
  • Optimistic locking – sets MxObjectVersion if the entity uses it

Generate Database Connectors

For ongoing integration (not one-time import), generate Database Connector entities and microflows:

-- Auto-generate non-persistent entities and query microflows
SQL legacy GENERATE CONNECTOR INTO Integration
  TABLES (customers, orders, products);

This creates:

  • Non-persistent entities with mapped attributes
  • Constants for connection strings (JDBC URL, username, password)
  • DATABASE CONNECTION definitions with query microflows

Credential Management

Database credentials should never be hardcoded. mxcli supports:

  • Environment variables – set MXCLI_SQL_<ALIAS>_DSN
  • YAML config file – ~/.mxcli/sql.yaml with per-alias DSN entries
  • Credential isolation – credentials are never exposed to the AI agent or logged

Phase 4: Validation and Handoff

mxcli provides a multi-level validation pipeline. The agent uses these tools to self-correct before a human ever looks at the result.

Validation Steps

# 1. Syntax check (fast, no project used)
mxcli check script.mdl --no-project

# 2. Reference validation (checks entity/microflow names exist)
mxcli check script.mdl -p app.mpr --references

# 3. Lint the full project (19 built-in + 31 Starlark rules)
mxcli lint -p app.mpr

# 4. Quality report (scored 0-100 per category)
mxcli report -p app.mpr --format markdown

# 5. Mendix compiler check (requires Docker)
mxcli docker check -p app.mpr

# 6. Build and run (requires Docker)
mxcli docker run -p app.mpr

Lint Categories

The linter covers 6 categories:

CategoryFocusExample Rules
Naming (CONV)Naming conventionsEntity naming, microflow prefixes
Security (SEC)Access controlMissing access rules, open security
Quality (QUAL)Code qualityUnused variables, empty microflows
Architecture (ARCH)StructureModule dependencies, circular references
Performance (DESIGN)EfficiencyMissing indexes, large retrieve-all
Design (MDL)Best practicesEntity design, association patterns

Quality Reports

The assess-quality skill guides the agent to run mxcli report and interpret the scores. A migration is ready for handoff when:

  • No critical lint issues (SEC, QUAL categories)
  • Quality score above 70 across all categories
  • mxcli docker check passes with no errors
  • All tests pass

Automated Testing

Write .test.mdl files to validate migrated business logic:

-- tests/order_tests.test.mdl
-- @test: Order total is calculated correctly

$Customer = CREATE CRM.Customer (Name = 'Test');
COMMIT $Customer;

$Order = CREATE Sales.Order (OrderNumber = 'ORD-001', OrderDate = now());
CHANGE $Order (Sales.Order_Customer = $Customer);
COMMIT $Order;

$Line = CREATE Sales.OrderLine (Price = 10.00, Quantity = 3);
CHANGE $Line (Sales.OrderLine_Order = $Order);
COMMIT $Line;

CALL MICROFLOW Sales.ACT_Order_CalculateTotal (Order = $Order);

-- @assert: $Order/TotalAmount = 30.00
# Run tests (Docker, or --local for mxcli's own runtime)
mxcli test tests/ -p app.mpr

Handoff to Studio Pro

The final step transitions to Mendix Studio Pro for visual refinement:

TaskWhy Studio Pro
Page layout tuningVisual drag-and-drop for pixel-perfect layouts
Styling and themingCSS/SCSS editing with live preview
Complex workflowsWorkflow editor for multi-step approval processes
Marketplace modulesInstall and configure marketplace modules
DeploymentConfigure deployment pipelines and environments

Handoff Checklist

# Final validation before opening in Studio Pro
mxcli docker check -p app.mpr          # No compiler errors
mxcli lint -p app.mpr                  # No critical lint issues
mxcli report -p app.mpr               # Review quality scores
mxcli test tests/ -p app.mpr          # All tests pass
mxcli -p app.mpr -c "DESCRIBE STRUCTURE DEPTH 2"  # Review structure

Iterative Workflow

The mxcli/Studio Pro workflow is iterative – you can switch between them:

  mxcli/MDL                    Studio Pro
 ┌──────────┐               ┌──────────────┐
 │ Generate  │──── open ───▶│ Visual edit   │
 │ entities, │               │ styling,     │
 │ microflows│◀── save ─────│ test, deploy  │
 │ pages     │               │              │
 └──────────┘               └──────────────┘

Changes made in either tool are persisted in the .mpr file. Always close mxcli before opening in Studio Pro, and vice versa.

MDL Basics

MDL (Mendix Definition Language) is a SQL-like language for reading and modifying Mendix application projects. It provides a text-based alternative to the visual editors in Mendix Studio Pro.

What MDL Looks Like

MDL uses familiar SQL-style syntax with Mendix-specific extensions. Here is a simple example that creates an entity with attributes:

CREATE PERSISTENT ENTITY Sales.Customer (
  CustomerId: AutoNumber NOT NULL UNIQUE DEFAULT 1,
  Name: String(200) NOT NULL,
  Email: String(200) UNIQUE,
  IsActive: Boolean DEFAULT TRUE
)
INDEX (Name);

Statement Termination

Every statement ends with a semicolon (;):

mdl 1;
CREATE MODULE OrderManagement;

CREATE PERSISTENT ENTITY Sales.Order (
  OrderId: AutoNumber NOT NULL UNIQUE DEFAULT 1,
  OrderDate: DateTime NOT NULL,
)
INDEX (OrderDate DESC);

A missing ; is an error, and so is the Oracle SQL*Plus-style / on its own line. At the REPL and in mxcli -c "…" the end of the input ends the last statement, so a single command such as list entities needs no terminator there.

Trailing Commas

A trailing comma is allowed in every bracketed list — attributes, enumeration values, parameters, property lists, { … } blocks — under every language version. () is the only way to write an empty list: (,) and (a,,) are errors.

Keyword Case

Keywords are case-insensitive, and lowercase is canonical: describe writes them in lowercase and mxcli fmt normalizes them to it. Names are not keywords, even when they are spelled like one — a module member User, an attribute Title or a property key Folder: keeps its case, and so does a CamelCase value such as ButtonStyle: Success or a type name such as String(200). Expressions, XPath, OQL and SQL are stored as written, so fmt leaves their text alone.

One Spelling per Keyword

Each keyword has one spelling, and a page action uses the words a microflow uses: show page, save changes, cancel changes, close page, create object, delete, open link, sign out, complete task, open user task, open workflow, call microflow M.F, call nanoflow M.F, call workflow M.WF. A validation message is error message '…' (after not null, unique, required, in a validation rule and after on delete restrict); a delete behaviour is the SQL referential action, on delete cascade / restrict / set null; a reference set’s type is ReferenceSet; a REST call that returns nothing says returns nothing.

One Verb per Job

list enumerates, describe shows one thing, and an alter adds and drops its children:

  • describe page M.P, describe app security, describe security matrix [in M], describe structure …, describe context of X;
  • list navigation, list navigation menu [profile], list settings (they print tables, so they are listings);
  • alter user role R drop module roles (…), alter settings language drop '…', alter settings workflows drop group '…';
  • alter entity E add|rename|modify|drop attribute …;
  • call rest service get '…' …, describe widget type combobox, create fragment F as { … }.

show entity X and show association X are not mdl 1: describe entity X prints the definition as MDL, and list entities in M / list associations in M print the summary columns. show version, show status, show connections and show catalog status report the session, not the model: they are session commands (R7), typed at the REPL.

Activity Settings

A microflow or nanoflow activity says what it does in words, and lists its settings in one property list (ADR-0013):

  • Words carry the action and its data flow: the verb, the result variable ($x =), the main operand (the method and URL, the called document, the object), returns … and on error ….
  • One ( Key: value, … ) list after the main operand carries what a Studio Pro user sets in the activity’s properties dialog: headers, authentication, timeout, request body. : sets a setting, = binds a value, trailing commas are allowed, and an unknown key is an error.
$Html = call rest service get 'https://example.com' (
  Headers: ('Accept': 'text/html'),
  Timeout: 300,
) returns String;

The keys are named as the matching document property is (Headers, Authentication, Timeout, as on a consumed REST service). An activity with one or two settings that read naturally as words keeps its clauses — log info node 'App' 'text', show message 'Saved' type Information.

Document Type Names

Document types are named as Studio Pro names them: consumed rest service(s), consumed odata service(s), published odata service(s), task queue(s), alter app security …, alter settings runtime …, list image collections / list icon collections / list message definition collections, ai model(s) (the agent editor’s model document), and create json structure M.J sample '…'.

The older spellings of all three sections still parse in a script file with the same meaning, warn with an MDL-DEPR* code, and mxcli fmt --upgrade rewrites them; Language Versions and Migration lists every one.

consumed web service, published web service and xml schema are reserved: MDL does not support these Studio Pro documents yet, and a statement that names one is refused with an error saying so.

business event service and database connection keep their names: a Mendix 10+ business event service document holds both the published and the subscribed operations, and database connection is the Database Connector’s own name for the document.

Language Version Header

A script starts with a header that names the MDL language version it is written in:

mdl 1;

create persistent entity Sales.Customer (
  Name: String(200)
);
  • mdl 1 is the language this manual documents. It was frozen at beta: a script headed mdl 1; means the same under every later mxcli release.
  • The header is the first statement. It may be repeated later with the same version — descriptions concatenated into one file each start with it — but a header naming another version is an error, and so is a version this mxcli does not know.
  • describe writes mdl 1; at the top of every description, so a description runs as it is, and mxcli fmt --upgrade adds it to an older script.
  • At the REPL and in mxcli -c "…", input without a header is read as mdl 1.
  • A script file without a header is the older language, mdl 0, and keeps its meaning (constructs that mean something else under mdl 1 warn). How it differs, and how to upgrade it, is on Language Versions and Migration.
  • It is independent of the Mendix version your project targets.

What mdl 1 makes strict:

  • Every statement ends with ;, and a / terminator line is an error.
  • '' is the only string escape; a backslash is an ordinary character, so 'C:\temp' is that path.
  • A text template (log, show message, validation feedback) written as one literal is the template text even when it spans lines, which is how a template holds a line break.
  • In a REST client, published REST service, business event service, model, knowledge base, consumed MCP service or agent, an unknown property key is an error that names the key it most likely meant, and so is a value its key does not take (Response: json from $X).
  • A while loop is while <condition> begin … end while;; leaving out begin, or the while after end, is an error.
  • show entity X / show association X is an error: describe entity X prints the definition, list entities in M the summary columns.
  • A session command — connect, disconnect, use, set format = …, status, show version, show status, show connections, show catalog status, check, build, lint, debug, execute script, execute runtime, help, introspect api — is an error in a script. Type it at the REPL, or use the command-line flag (mxcli exec script.mdl -p app.mpr --json).
  • DynamicClasses, DynamicCellClass and an OData client’s credential or header value take the expression bare; a quoted value is a Mendix string.

The manual, the skills, mxcli syntax and the example scripts are written in the canonical form, and CI holds them to it: make check-conformance parses every MDL block in them as mdl 1 and fails on a deprecated spelling (mxcli check --deprecations=error does the same for a script of your own; add --v1=error to also fail on what a headerless script must change before it can carry mdl 1;). The design is in ADR-0011; mxcli syntax language-header has the details.

Re-runnable Creates: or modify and if not exists

A plain create fails when the element already exists. Two guards make a script re-runnable, and they mean different things:

StatementElement absentElement present
create page M.P …createderror — the script stops
create or modify page M.P …createdrewritten to match the statement (identity kept)
create page if not exists M.P …createdleft untouched, reported as skipped

if not exists goes after the kind’s keywords and before the name, on every create that names one element — create microflow if not exists M.MF () …, create module if not exists M;, create user role if not exists Clerk (M.User);, create configuration if not exists 'Default' (…);. It is not accepted on annotation, index (use alter entity … add index if not exists), validation rule, navigation, translations or external entities, which have no single named element to test. Writing create or modify … if not exists is refused as MDL085: the two guards contradict each other. describe never emits if not exists.

Case Insensitivity

All MDL keywords are case-insensitive. The following are equivalent:

CREATE PERSISTENT ENTITY Sales.Customer ( ... );
create persistent entity Sales.Customer ( ... );
Create Persistent Entity Sales.Customer ( ... );

Identifiers (module names, entity names, attribute names) are case-sensitive and must match the Mendix model exactly.

Statement Categories

MDL statements fall into several categories:

CategoryExamples
QueryLIST ENTITIES, DESCRIBE ENTITY, SEARCH
Domain ModelCREATE ENTITY, CREATE ASSOCIATION, ALTER ENTITY
EnumerationsCREATE ENUMERATION, ALTER ENUMERATION
MicroflowsCREATE MICROFLOW, DROP MICROFLOW
PagesCREATE PAGE, ALTER PAGE, CREATE SNIPPET
SecurityGRANT, REVOKE, CREATE USER ROLE
NavigationCREATE OR REPLACE NAVIGATION
ConnectionCONNECT LOCAL, DISCONNECT, STATUS

Further Reading

Lexical Structure

This page describes the tokens that make up the MDL language: keywords, literals, and identifiers.

Keywords

MDL keywords are case-insensitive. The following are reserved keywords:

ACCESS, ACTIONS, ADD, AFTER, ALL, ALTER, AND, ANNOTATION, AS, ASC,
ASCENDING, ASSOCIATION, AUTONUMBER, BATCH, BEFORE, BEGIN, BINARY,
BOOLEAN, BOTH, BUSINESS, BY, CALL, CANCEL, CAPTION, CASCADE,
CATALOG, CHANGE, CHILD, CLOSE, COLUMN, COMBOBOX, COMMIT, CONNECT,
CONFIGURATION, CONNECTOR, CONSTANT, CONSTRAINT, CONTAINER, CREATE,
CRUD, DATAGRID, DATAVIEW, DATE, DATETIME, DECLARE, DEFAULT, DELETE,
DELETE_BEHAVIOR, DELETE_BUT_KEEP_REFERENCES, DELETE_AND_REFERENCES, DEMO,
DEPTH, DESC, DESCENDING, DESCRIBE, DIFF, DISCONNECT, DROP, ELSE,
EMPTY, END, ENTITY, ENUMERATION, ERROR, EVENT, EVENTS, EXECUTE,
EXEC, EXIT, EXPORT, EXTENDS, EXTERNAL, FALSE, FOLDER, FOOTER,
FOR, FORMAT, FROM, FULL, GALLERY, GENERATE, GRANT, HEADER, HELP,
HOME, IF, IMPORT, IN, INDEX, INFO, INSERT, INTEGER, INTO, JAVA,
KEEP_REFERENCES, LABEL, LANGUAGE, LAYOUT, LAYOUTGRID, LEVEL, LIMIT,
LINK, LIST, LISTVIEW, LOCAL, LOG, LOGIN, LONG, LOOP, MANAGE, MAP,
MATRIX, MENU, MESSAGE, MICROFLOW, MICROFLOWS, MODEL, MODIFY, MODULE,
MODULES, MOVE, NANOFLOW, NANOFLOWS, NAVIGATION, NODE, NON_PERSISTENT,
NOT, NULL, OF, ON, OR, ORACLE, OVERVIEW, OWNER, PAGE, PAGES, PARENT,
PASSWORD, PERSISTENT, POSITION, POSTGRES, PRODUCTION, PROJECT,
PROTOTYPE, QUERY, QUIT, REFERENCE, REFERENCESET, REFRESH, REMOVE,
REPLACE, REPORT, RESPONSIVE, RETRIEVE, RETURN, REVOKE, ROLE, ROLES,
ROLLBACK, ROW, SAVE, SCRIPT, SEARCH, SECURITY, SELECTION, SET, SHOW,
SNIPPET, SNIPPETS, SQL, SQLSERVER, STATUS, STRING, STRUCTURE,
TABLES, TEXTBOX, TEXTAREA, THEN, TO, TRUE, TYPE, UNIQUE, UPDATE,
USER, VALIDATION, VALUE, VIEW, VIEWS, VISIBLE, WARNING, WHERE, WIDGET,
WIDGETS, WITH, WORKFLOWS, WRITE

Most keywords work unquoted as identifiers (entity names, attribute names). Only structural keywords like CREATE, DELETE, BEGIN, END, RETURN, ENTITY, and MODULE require quoting when used as identifiers.

Quoting escapes parser keywords only — not platform-reserved member names. Quoting an identifier tells the MDL parser to treat it as a name rather than a token, so "create", "status", and "type" parse fine as attribute names. But some names are reserved by the Mendix platform, and Studio Pro rejects them even when quoted (the quotes are stripped, and the bare name is still invalid): Type (CE7247), the audit attributes CreatedDate / ChangedDate / Owner / ChangedBy, plus ID, GUID, CurrentUser, and the Java keyword list. mxcli check --references flags these as MDL021 (CE7247) / MDL020. Rename them (e.g. Type → ResourceType); use the AutoCreatedDate / AutoChangedDate / AutoOwner / AutoChangedBy pseudo-types for the audit fields.

Literals

String Literals

String literals use single quotes:

'single quoted string'
'it''s here'            -- doubled single quote to escape

The only escape sequence is '' (two single quotes) to represent a literal single quote, as in a Mendix expression. Under mdl 1; a backslash is an ordinary character: 'C:\temp' is that path.

A string in a microflow or nanoflow expression is read by the same rule as any other string, and the expression stores its value the way Studio Pro does: an apostrophe doubled, a backslash and a line break as themselves. 'C:\temp' stores 'C:\temp', whether the builder renders the expression or stores it as written.

A string in an XPath constraint follows the same rule, wherever the XPath is written: a retrieve’s where, an entity access rule, a page datasource, a workflow user task’s targeting xpath, and a navigation sync constraint. So do the Mendix expressions a statement stores as written beside them — a retrieve’s limit and offset, a database query’s dynamic expression. describe writes each back as stored.

A line break is written into the literal itself: a string may span lines, and the break is part of its value. This is also how a text template — the message of log, show message or validation feedback — holds a line break; under mdl 1; a template written as one literal is the template text whether or not it spans lines:

mdl 1;
create microflow Shop.LogCleanup ($Count: Integer)
begin
  log info node 'Shop' 'Deleted {1} records.
Run again tomorrow.' with ({1} = toString($Count));
end;

describe writes every string this way — a caption, an annotation, a widget property, a string in an expression: '' is its only escape and a stored line break is a line break in the literal.

With with ({n} = …) parameters the literal is the template text too. Strings in a script without the header follow the older rules on Language Versions and Migration.

Numeric Literals

42          -- Integer
3.14        -- Decimal
-100        -- Negative integer
1.5e10      -- Scientific notation

Boolean Literals

TRUE
FALSE

Quoted Identifiers

When an identifier collides with a reserved keyword, use double quotes (ANSI SQL style) or backticks (MySQL style):

"ComboBox"."CategoryTreeVE"
`Order`.`Status`
"ComboBox".CategoryTreeVE    -- mixed quoting is allowed

See Qualified Names for more on identifier syntax and the Module.Name notation.

Qualified Names

MDL uses dot-separated qualified names to reference elements within a Mendix project. This naming convention ensures elements are unambiguous across modules.

Simple Identifiers

Valid identifier characters:

  • Letters: A-Z, a-z
  • Digits: 0-9 (not as first character)
  • Underscore: _
MyEntity
my_attribute
Attribute123

Qualified Name Format

The general format is Module.Element or Module.Entity.Attribute:

MyModule.Customer           -- Entity in module
MyModule.OrderStatus        -- Enumeration in module
MyModule.Customer.Name      -- Attribute in entity

Two-Part Names

Most elements use a two-part name: Module.ElementName.

Sales.Customer              -- entity
Sales.OrderStatus           -- enumeration
Sales.ACT_CreateOrder       -- microflow
Sales.Customer_Edit         -- page
Sales.Order_Customer        -- association

Three-Part Names

Attribute references use three parts: Module.Entity.Attribute.

Sales.Customer.Name         -- attribute on entity
Sales.Order.TotalAmount     -- attribute on entity

Quoting Rules

When a name segment is a reserved keyword, wrap it in double quotes or backticks:

"ComboBox"."CategoryTreeVE"
`Order`.`Status`

Mixed quoting is allowed – you only need to quote the segments that conflict:

"ComboBox".CategoryTreeVE
Sales."Order"

Identifiers that contain only letters, digits, and underscores (and do not start with a digit) never need quoting.

Usage in Statements

Qualified names appear throughout MDL:

-- Entity creation
CREATE PERSISTENT ENTITY Sales.Customer ( ... );

-- Association referencing two entities
CREATE ASSOCIATION Sales.Order_Customer
  FROM Sales.Customer
  TO Sales.Order
  TYPE Reference;

-- Enumeration reference in an attribute type
Status: Enumeration(Sales.OrderStatus) DEFAULT 'Active'

-- Microflow call
$Result = CALL MICROFLOW Sales.ACT_ProcessOrder (Order = $Order);

See Also

Comments and Documentation

MDL supports three comment styles for annotating scripts and attaching documentation to model elements.

Single-Line Comments

Use -- (SQL style) or // (C style) for single-line comments:

-- This is a single-line comment
LIST MODULES;

// This is also a single-line comment
LIST ENTITIES IN Sales

Everything after the comment marker to the end of the line is ignored.

Multi-Line Comments

Use /* ... */ for comments that span multiple lines:

/* This comment spans
   multiple lines and is useful
   for longer explanations */
CREATE PERSISTENT ENTITY Sales.Customer (
  Name: String(200) NOT NULL
);

Documentation Comments

Use /** ... */ to attach documentation to model elements. Documentation comments are stored in the Mendix model and appear in Studio Pro:

/** Customer entity stores master customer data.
 *  Used by the Sales and Support modules.
 */
@Position(100, 200)
CREATE PERSISTENT ENTITY Sales.Customer (
  /** Unique customer identifier, auto-generated */
  CustomerId: AutoNumber NOT NULL UNIQUE DEFAULT 1,

  /** Full legal name of the customer */
  Name: String(200) NOT NULL,

  /** Primary contact email address */
  Email: String(200) UNIQUE
);

Documentation comments can be placed before:

  • Entity definitions (becomes entity documentation)
  • Attribute definitions (becomes attribute documentation)
  • Enumeration definitions (becomes enumeration documentation)
  • Association definitions (becomes association documentation)

Not before an enumeration value or an index: Mendix stores no documentation on a value and no name on an index, so check warns that the text is not kept (MDL-ENUMDOC01, MDL-IDX01). Use an ordinary -- comment for a note there.

Updating Documentation

You can also set documentation on existing entities with ALTER ENTITY:

ALTER ENTITY Sales.Customer
  SET DOCUMENTATION 'Customer master data for the Sales module';

Script Files

MDL statements can be saved in .mdl files and executed as scripts. This is the primary way to automate Mendix model changes.

File Format

  • Extension: .mdl
  • Encoding: UTF-8
  • Language header: a script starts with mdl 1;, the MDL language version it is written in (mxcli fmt --upgrade --header adds it to an older script)
  • Statement termination: every statement ends with a semicolon (;)

A typical script file:

mdl 1;
-- setup_domain_model.mdl
-- Creates the Sales domain model

CREATE ENUMERATION Sales.OrderStatus (
  Draft 'Draft',
  Pending 'Pending',
  Confirmed 'Confirmed',
  Shipped 'Shipped'
);

CREATE PERSISTENT ENTITY Sales.Customer (
  Name: String(200) NOT NULL,
  Email: String(200) UNIQUE
);

CREATE PERSISTENT ENTITY Sales.Order (
  OrderDate: DateTime NOT NULL,
  Status: Enumeration(Sales.OrderStatus) DEFAULT 'Draft'
);

CREATE ASSOCIATION Sales.Order_Customer
  FROM Sales.Customer
  TO Sales.Order
  TYPE Reference;

Execution Modes

mxcli supports several ways to execute MDL. All modes open a single connection to the project — there is no per-command overhead.

Script File

Use mxcli exec to run a .mdl file:

mxcli exec setup_domain_model.mdl -p /path/to/app.mpr

Inline Commands (-c)

Pass one or more semicolon-separated commands with the -c flag:

# Single command
mxcli -p app.mpr -c "LIST ENTITIES"

# Multiple commands in one connection
mxcli -p app.mpr -c "DESCRIBE ENTITY Sales.Customer; DESCRIBE ENTITY Sales.Order; LIST MICROFLOWS IN Sales"

This is the fastest way for AI agents to batch multiple queries — all commands share a single connection.

Stdin Piping

When stdin is a pipe (not a terminal), mxcli reads commands from it in quiet mode (no banner, no prompts):

# Pipe from echo
echo "LIST ENTITIES; LIST MICROFLOWS" | mxcli -p app.mpr

# Pipe from file
mxcli -p app.mpr < commands.mdl

# Pipe from heredoc
mxcli -p app.mpr <<'EOF'
DESCRIBE ENTITY Sales.Customer;
DESCRIBE ENTITY Sales.Order;
LIST MICROFLOWS IN Sales;
EOF

From the REPL

Use EXECUTE SCRIPT inside an interactive session:

CONNECT LOCAL '/path/to/app.mpr';
EXECUTE SCRIPT './scripts/setup_domain_model.mdl';

Syntax Checking

You can validate a script without connecting to a project:

# Syntax only (--no-project: do not auto-detect an .mpr in this directory)
mxcli check script.mdl --no-project

# Syntax + reference validation (requires project)
mxcli check script.mdl -p app.mpr --references

Syntax checking catches parse errors, unknown keywords, and common anti-patterns before execution.

Comments in Scripts

Scripts support the same comment syntax as interactive MDL:

-- Single-line comment
/* Multi-line comment */
/** Documentation comment (attached to next element) */

Language Versions and Migration

An MDL script is written in one language version, declared by its first statement:

mdl 1;

create persistent entity Shop.Customer (
  Name: String(200)
);

A script file with no header is mdl 0, the alpha language. The rest of this manual documents mdl 1; this page is the one place that documents what mdl 0 means differently, and how to move a script from one to the other.

Two rules make the move safe (ADR-0011):

  • A script never changes meaning because a newer mxcli runs it. A change of meaning, or a new rejection, applies only under the header that introduces it. Without the header the old meaning is kept, and the construct warns with an MDL-V1-* code.
  • A deprecated spelling is a respelling. It means exactly what its new form means, warns with an MDL-DEPR* code, and mxcli fmt --upgrade rewrites it. It is refused only from the version named in its entry.

Every warning that carries one of these codes ends with a pointer such as (mxcli help MDL-V1-LIMIT1). That command prints the code’s entry: the old form, the new form, whether fmt --upgrade rewrites it, and the version that refuses it. mxcli syntax <topic> --deprecated lists the old spellings of one topic.

mdl 1 is frozen

mdl 1 was frozen at beta. It is a contract: a later change of meaning needs a new version, mdl 2, and a script headed mdl 1; means the same under every later mxcli release. What that changes in practice:

WhereSince the freezeTo get mdl 0
describe (statement and mxcli describe), mxcli context, mxcli diff-localWrites mdl 1, and every description starts with mdl 1;, so it runs as it is. Descriptions concatenated into one file repeat the header; a repeated header naming the same version is allowed. Describe never writes a deprecated spelling, in either version: a List operation or Aggregate list activity is its statement ($n = count $L;), never the call (count($L)).--mdl 0 on the command (no header is written)
mxcli fmt --upgradeAdds the mdl 1; header by default, after the rewrites below.--header=false upgrades the spellings alone
mxcli fmt (without --upgrade)Keeps the script’s header, or its lack of one: formatting never changes a script’s language.—
The REPL and mxcli -c "…"Input without a header is read as mdl 1. Session commands (connect, set format, status, …) and a missing ; after the last statement stay allowed: this is the session they belong to.--mdl 0 on the command line; in the REPL an mdl 0; statement switches the session (and mdl 1; back). The REPL is the only place a header may change the language part-way
A script file run with exec, check or fmtUnchanged: read by its own header, and as mdl 0 without one (ADR-0011).—

Before the freeze mdl 1; warned “preview: may still change” (MDL-LANG01); that warning is gone. A header after the first statement must name the same version as the first: mdl 0; after mdl 1; is an error, and so is mdl 1; part-way through a script that started without one.

What a script without the header means

Each construct below is refused under mdl 1; and accepted without the header, where it keeps its old meaning and warns with the code shown.

Under mdl 1;Without the header
A statement without ; is an error.Accepted; MDL-V1-SEMI.
A / terminator line is an error.Accepted; MDL-V1-SLASH.
'' is the only string escape; a backslash is an ordinary character, so 'C:\temp' is that path.\n, \r, \t, \\ and \' are escapes; MDL-V1-ESCAPE for each literal whose value would change.
A text template (log, show message, validation feedback) written as one literal is the template text even when it spans lines, which is how a template holds a line break.A template literal spanning lines with no with ({n} = …) parameters is an expression: the template is {1}, the literal its parameter; MDL-V1-TEMPLATE.
In a REST client, published REST service, business event service, model, knowledge base, consumed MCP service or agent, an unknown property key is an error that names the key it most likely meant, and so is a value its key does not take (Response: json from $X).The property is ignored, or read by its shape as before; MDL-V1-PROP / MDL-V1-PROPVALUE.
A while loop is while <condition> begin … end while;; leaving out begin, or the while after end, is an error.Accepted; MDL-V1-WHILE.
show entity X / show association X is an error: describe entity X prints the definition, list entities in M the summary columns.Prints the summary; MDL-V1-SHOWSUMMARY.
A session command — connect, disconnect, use, set format = …, status, show version, show status, show connections, show catalog status, check, build, lint, debug, execute script, execute runtime, help, introspect api — is an error in a script. Type it at the REPL, or use the command-line flag (mxcli exec script.mdl -p app.mpr --json). The REPL keeps accepting them.Runs as before; MDL-V1-SESSION.
A quoted value of DynamicClasses, DynamicCellClass or an OData client’s credential or header value is a Mendix string, so the pre-#750 spelling — the expression’s text in quotes, 'if $currentObject/X then ''on'' else ''''', '''admin''', '@Mod.C' — is an error (MDL-WIDGET33, MDL-ODATA07). Write the expression bare.The quoted text is the expression it holds; MDL-V1-QUOTEDEXPR. A quoted class name or plain credential is the string under both.

Strings without the header

A script without the header (mdl 0) still reads a backslash before n, r, t, \ or ' as an escape — 'C:\temp' holds a tab — and check warns MDL-V1-ESCAPE for each literal whose value changes under mdl 1. Write the backslash-free form ('it''s') to mean the same under both.

In a microflow or nanoflow expression, without the header 'C:\\temp' stores 'C:\temp' and 'it\'s' stores 'it''s': the stored expression is the same value, spelled as Studio Pro spells it.

describe --mdl 0 keeps the mdl 0 escapes (\n, \\): in a flow’s expression and an XPath constraint it writes a stored backslash as \\, so the description reads back as the stored value under mdl 0. mxcli fmt --upgrade rewrites an mdl 0 script’s escaped strings to the mdl 1 form. An expression whose string holds an escaped line break is written as the expression Mendix stores for it, since an expression that spans lines is stored exactly as written.

Without the header a template literal that spans lines and has no parameters is an expression instead — the template is {1} and the literal its parameter — and check warns MDL-V1-TEMPLATE. With with ({n} = …) parameters it is the template text under both versions.

How to migrate a script

  1. Commit the script first, so the upgrade is one reviewable diff.

  2. Upgrade it with the project it runs against, and let the upgrade add the header. Never type mdl 1; by hand: the upgrade adds it only after rewriting every construct whose meaning the header would change, and refuses when one has no rewrite.

    mxcli fmt --upgrade -w -p app.mpr script.mdl
    

    The header is the default since the freeze; --header still says so explicitly, and --header=false upgrades the spellings alone.

    The project (-p) answers what the script alone cannot: whether find(…) over a flow’s result is the string function or the list operation, which bare commit statements the stored flows run without events, and which create or modify statements exec would refuse under the header. The project is only read.

  3. Read what it reports on stderr. A deprecated spelling with no mechanical rewrite is left in place and listed as “not upgraded”; a header-gated construct with no rewrite blocks the header, and the file keeps its version. Fix those by hand (mxcli help <code> says how) and upgrade again.

  4. Check it against the project, the way exec will read it:

    mxcli check script.mdl -p app.mpr --references
    

    check -p reports everything exec would refuse under the header, computed by the same code exec uses, so a refusal shows up here and not halfway through a run.

    To gate a script that is meant to be ready for the header — in CI, say — pass --deprecations=error --v1=error: the first fails on a deprecated spelling (MDL-DEPRnnn), the second on an MDL-V1-* warning, which only a headerless script reports. Either alone lets the other through.

  5. Run it twice. The first exec applies the script; the second must write nothing, and report every document unchanged. A second run that writes is a script that does not say what the project holds — the upgrade’s job is to make it say so — and is worth reporting.

    mxcli exec script.mdl -p app.mpr
    mxcli exec script.mdl -p app.mpr   # expect: nothing written
    

Test files (.test.mdl, .test.md) are upgraded the same way: the statements in their blocks are rewritten, and doc comments (@test, @expect, …), separators and prose are kept byte for byte. The header goes on the first line of a .test.mdl file and on the first line inside each mdl-test block of a .test.md file; --header=false declines it, as for a script. The runner and check read it, so a test body means under the header what it means in a script (see Test Formats).

One dialect per file. Upgrade a headerless file before editing it; do not add mdl 1 statements to an mdl 0 file.

What the upgrade does, in order

mxcli fmt --upgrade changes nothing but the constructs below; comments, layout and keyword case survive, and the result is re-parsed before it is written.

  1. Deprecated spellings become their new form (the MDL-DEPR* table). This step runs with or without the header.
  2. Bare commits are pinned (with -p, with or without the header). A bare commit $X; means with events, Studio Pro’s default; mxcli releases before that change stored it without. In a create or modify flow whose stored commit has no events, the upgrade writes commit $X without events; so that re-running the script keeps what is stored. Without -p the script is left as written and fmt prints a note (MDL067) per flow.
  3. Header-gated constructs are rewritten to the spelling that keeps their mdl 0 meaning under the header (not with --header=false): the missing ; is added, / lines are deleted, a backslash escape becomes the character it stood for, retrieve … limit 1 (an object under mdl 0) becomes retrieve … first, a reassignment gets set, and a list operation becomes one statement per activity. One change is version-neutral and is rewritten without the header too (the table says which).
  4. The header is added — unless a construct in step 3 has no rewrite, or, with -p, exec would refuse a statement under the header (a create or modify of a stored flow whose change cannot be spliced in, MDL-V1-REBUILD). The file then keeps its version and the rest of the upgrade still applies; --force-header adds the header anyway.

The rewrites and refusals in detail

mxcli fmt --upgrade rewrites every deprecated spelling (the MDL-DEPRnnn warnings) to its canonical form — create or replace becomes create or modify, list entities becomes list entities, on error { … } becomes on error begin … end error — and changes nothing else: comments, layout and keyword case are kept. A deprecated use with no mechanical rewrite is reported and left in place.

mxcli fmt --upgrade script.mdl                  # print the upgraded script
mxcli fmt --upgrade -w script.mdl               # upgrade in place, adding `mdl 1;`
mxcli fmt --upgrade --header=false -w script.mdl  # the spellings alone, no header

The header is added after rewriting every construct whose meaning it would change, so the script keeps doing what it did:

CodeRewrite
MDL-V1-SEMIadds the missing ;
MDL-V1-SLASHdeletes the / line
MDL-V1-ESCAPEwrites the string’s value with '' as the only escape ('it\'s\t' becomes 'it''s + a tab + ')
MDL-V1-LIMIT1retrieve … limit 1 (one object) becomes retrieve … first
MDL-V1-SET$x = … becomes set $x = …
MDL-V1-LIST, MDL-DEPR003, MDL-DEPR004a list operation or aggregate call becomes its statement form ($x = filter($L, …) → $x = filter $L where …); find/contains on a declared String keeps the call and gains set
MDL-V1-REPLACE02create or replace user role / demo user becomes a plain create
MDL-V1-WHILEinserts the missing begin after a while condition and while after its end
MDL-V1-TEMPLATEa template literal spanning lines becomes the parameter it was: log info 'a⏎b'; becomes log info '{1}' with ({1} = 'a⏎b');
MDL-V1-QUOTEDEXPRa quoted expression becomes the expression bare: dynamicclasses: 'if $currentObject/X then ''on'' else ''''' becomes dynamicclasses: if $currentObject/X then 'on' else ''. The bare form means the same without the header, so fmt --upgrade applies this one with --header=false too

A construct with no mechanical rewrite is reported with the reason, and fmt refuses to add the header rather than change the script’s meaning: an unknown or mis-shaped property (MDL-V1-PROP, MDL-V1-PROPVALUE), create or replace view entity (MDL-V1-REPLACE01), a session command in a script (MDL-V1-SESSION: move it to the command line or the REPL), a nested list operation such as count(filter(…)), find/contains on a variable whose type the script does not state (when the variable holds a microflow or nanoflow call’s result, the called flow’s return type decides: a flow the script creates earlier is read from the script, and any other from the project given with -p app.mpr, so fmt --upgrade -p app.mpr rewrites it), and an escaped line break (\n) inside an expression. An escaped line break in a text template’s literal is rewritten: the break is written into the literal, which under mdl 1 is still the template text. Running fmt --upgrade on its own output changes nothing.

-p app.mpr also settles a bare commit $X; in a create or modify microflow|nanoflow, with or without the header. Since mendixlabs/mxcli#895 a bare commit means with events, Studio Pro’s default; an older mxcli stored the same statement without events. Where the stored flow commits the variable without events, fmt --upgrade -p app.mpr writes commit $X without events;, so re-running the script keeps what is stored instead of turning the event handlers on (or, inside a loop under mdl 1, being refused). A new flow, a new commit, or a stored commit with events is left as written; where the stored flow commits the variable both ways, the statement is left and reported. Without -p, fmt prints a note (MDL067) for each flow with a bare commit.

-p app.mpr also tells the upgrade which statements exec would refuse once the header is there. Under mdl 1 a create or modify microflow|nanoflow of a stored flow is applied as a patch, and a change the patch cannot make (inside a loop body anything but an activity replaced by one of the same kind, an error handler, a redrawn connector, a return added, a statement inserted without @position that needs room past a node the script places) is refused with nothing written; without the header the same statement rebuilds the whole flow (MDL-V1-REBUILD). No rewrite keeps that meaning, so fmt names each such statement and leaves the header off that file, applying the rest of the upgrade. Change those flows with alter, or drop and create them, and upgrade again; --force-header adds the header regardless. mxcli check script.mdl -p app.mpr reports the same statements (an MDL-V1-REBUILD error under mdl 1, the warning without the header), computed by the same code as exec and diff.

Behaviour changes to know about

Besides the generated tables, a few changes alter what an existing script writes. Most apply under both versions, because the old behaviour was a silent wrong write rather than a meaning a script could rely on (ADR-0011 allows fixing those everywhere); the others are gated like any MDL-V1-* change. They are listed because a script that ran before may write something different now. Where the old output can still be asked for, the entry says how.

ChangeWhat a script writes nowApplies underCode
String escapes in flow expressionsA string literal in a flow expression stores its value as Studio Pro spells it: '' doubled, never a backslash escape in the stored expression. Only how a \ in the MDL literal is read depends on the header (MDL-V1-ESCAPE).bothvisitor_string_escapes.go, mendixexpr.go
Pluggable widget schema keptAn unchanged pluggable widget keeps its stored Type and Object (the widget’s schema) instead of being rebuilt from mxcli’s template, so a widget upgraded in Studio Pro stays upgraded. A changed one keeps its stored schema and every property the statement does not change — a caption edit no longer resets a column’s attribute or a row click describe cannot print. Layouts included. A rewrite does not upgrade a widget to the installed package (only one whose data context the statement changes is rebuilt): use mxcli widget sync.bothcmd_pages_pluggable_passthrough.go, cmd_pages_pluggable_patch.go
Text-template attribute binding{1} = $Order.Total binds the attribute and renders with its formatting, as Studio Pro stores it, instead of toString($Order/Total). Write {1} = toString($Order/Total) to keep the old output. Without the header the binding warns (MDL-V1-TEMPLATEATTR).bothcmd_pages_template_attr.go
Gallery row clickA row click on a gallery with single-click selection — by create page, or by an alter page … set that leaves the gallery so — is refused (MDL-WIDGET36), with nothing written, because mx check fails such a page; without the header it is written and warns (MDL-V1-GALLERYCLICK).mdl 1validate_widget_language.go
create constant … privateWas never stored. Without the header it parses, does nothing and warns (MDL-DEPR138); fmt --upgrade deletes it; mdl 1 refuses it. Set a private value with mxcli constant set.bothvisitor_deprecations.go
Commit eventsA bare commit $X; commits with events, Studio Pro’s default; older releases stored it without. check names the affected flows (MDL067; exec -p leaves out a flow the project already stores with events), and fmt --upgrade -p writes without events where the stored flow has it.bothcommit_events.go, flow_commit_events.go
date as a typeMendix has no date-only type: date is an alias of DateTime and builds exactly what DateTime builds. Without the header it warns (MDL-DEPR160); fmt --upgrade writes DateTime; mdl 1 refuses date.bothdeprecation.go

Reference

The tables below are generated from the registries in the code (visitor.LanguageChanges, executor.LanguageChanges and mdl/deprecation) by make gen-migration-reference; CI fails when they are stale. A version above the newest one this mxcli knows (mdl 2 today) is the version that will refuse the spelling; until then it only warns.

Changes of meaning (MDL-V1-*)

25 constructs mean something different under the mdl 1; header. Without the header each keeps the meaning in the second column and warns with its code.

CodeWithout the header (mdl 0)Under mdl 1;Decided atRewritten by fmt --upgrade
MDL-V1-ACTIONSLOTAn onClick: or OnChange: on a widget with no slot for it is dropped on writea refusal (MDL-WIDGET37), with nothing writtenexecno: decided by exec against the project; check -p reports it
MDL-V1-BOUNDARYDROPdrop <activity> boundary event on an activity with several boundary events drops the first one describe workflow printsa refusal, with nothing writtenexecno: decided by exec against the project; check -p reports it
MDL-V1-ESCAPEa backslash in a string literal is an escape (\n is a newline, \t a tab, \' an apostrophe, \\ one backslash)an ordinary character, as in a Mendix expression; '' is the only escapeparseyes: fmt --upgrade --header
MDL-V1-GALLERYCLICKA gallery row click on a single-click selection is written, and mx check fails the page (“The item click action is ambiguous”)a refusal (MDL-WIDGET36), with nothing writtenexecno: decided by exec against the project; check -p reports it
MDL-V1-LIMIT1retrieve … limit 1 binds a single object (Mendix’s “First object” range, so a loop over it is CE0100 and count() or head() CE0097); write retrieve … first to say soa list of one (a Custom range), and the object is written firstparseyes: fmt --upgrade --header
MDL-V1-LISTa list operation or aggregate written as a call — find(…) or contains(…), a call after set, or one call nested in another — is turned into a List operation or Aggregate list activity, guessing from its arguments whether a string function was meant,an error: a List operation or Aggregate list is one statement per activity whose operand is a variable ($x = find $L where …;, $n = count $L;), and after set a call is always the expression functionparseyes: fmt --upgrade --header
MDL-V1-PROPan unknown property key is accepted and ignoredan errorparseno: blocks --header; an unknown property key is ignored under mdl 0 and an error under mdl 1; which key was meant cannot be guessed, so correct or delete it by hand
MDL-V1-PROPVALUEa value its key does not take is accepted, and ignored or read by its shapean errorparseno: blocks --header; a value its key does not take is ignored or read by its shape under mdl 0; what was meant cannot be guessed, so correct it by hand
MDL-V1-QUOTEDEXPRa quoted value holding expression text in an expression property (DynamicClasses, DynamicCellClass, an OData client’s HttpUsername / HttpPassword / ClientCertificate or header value) is that expression: the outer quotes are dropped and the doubled ones undonea Mendix string, so the old spelling is refused (MDL-WIDGET33, MDL-ODATA07); write the expression itself, without the outer quotes and with its own quotes singleparseyes: fmt --upgrade, with or without --header
MDL-V1-REBUILDcreate or modify microflow|nanoflow rebuilds the whole stored flow when the change cannot be spliced in, which resets curves, drops merges and renumbers element IDsa refusal that names the change the splice cannot make, with nothing writtenexecno: decided by exec against the project; check -p reports it
MDL-V1-REMOTETYPEcreate or modify external entity writes a mapped attribute with the declared type and keeps the service’s RemoteType, which mx check reports as CE6616a refusal that names the attribute and the type the service publishes, with nothing writtenexecno: decided by exec against the project; check -p reports it
MDL-V1-REPLACE01create or replace view entity deletes the view entity and creates a new one (a new identity; its access rules and associations are lost)create or modify view entity, which rewrites it in place and keeps its identityparseno: blocks --header; create or replace view entity drops and recreates the view entity under mdl 0, which no mdl 1 statement does: write create or modify view entity to keep its identity, or drop entity then create view entity to discard it
MDL-V1-REPLACE02create or replace on a user role or demo user is a plain create, which fails when it already existscreate or modifyparseyes: fmt --upgrade --header
MDL-V1-SEMIa statement without a terminating ; is acceptedan error: every statement ends with ;parseyes: fmt --upgrade --header
MDL-V1-SESSIONa session command in a script runs as if typed at the REPLan error: a script holds model statements only; type the command at the REPL, or use the command-line flag (-p app.mpr to connect, --json for the output format)parseno: blocks --header; a session command (connect, set format, status, help, …) is refused in an mdl 1 script, and no model statement does what it does: move it out of the script, to the command line (-p app.mpr to connect, --json for the output format) or the REPL
MDL-V1-SET$x = <expression> without set changes the variablean error: a reassignment is written set $x = <expression>;parseyes: fmt --upgrade --header
MDL-V1-SHOWSUMMARYshow entity X / show association X prints a summary of the elementan error: show is dropped (R6) and the summary has no mdl 1 statement; write describe entity X for the definition, or list entities in M / list associations in M for the summaryparseno: blocks --header; show entity X / show association X print a summary no mdl 1 statement prints: describe prints the definition as MDL and list entities / list associations the summary columns, so either would change the script’s output; choose one by hand
MDL-V1-SLASHa / after a statement is accepted as a terminator (SQL*Plus style)an error: ; is the only statement terminatorparseyes: fmt --upgrade --header
MDL-V1-TABPAGEPROPA property a tab page cannot store (CaptionParams, Class, Style, …) is accepted and dropped on writea refusal (MDL-WIDGET44), with nothing writtenexecno: decided by exec against the project; check -p reports it
MDL-V1-TEMPLATEa message template written as one string literal that spans lines is an expression: the template is {1} and the literal its parameterthe template text, as a literal on one line is; a line break in a template is written into the literalparseyes: fmt --upgrade --header
MDL-V1-TEMPLATEATTRa text-template parameter bound to a non-String attribute ({1} = $Order.Total) was stored as toString($Order/Total) by earlier mxcli releasesan attribute reference, rendered with the attribute’s formatting, as Studio Pro stores itexecno: decided by exec against the project; check -p reports it
MDL-V1-VIEWAUTONUMBERa view entity attribute declared autonumber over an AutoNumber column passes check, and mxbuild reports CE6770 “View Entity is out of sync with the OQL Query”a check error that names the attribute and suggests long, the type the view gives the columnexecno: decided by exec against the project; check -p reports it
MDL-V1-WHILEwhile <condition> accepts a body without begin and an end without whilean error: a while loop is while <condition> begin … end while;, like loop … begin … end loop;parseyes: fmt --upgrade --header
MDL-V1-WIDGETENUMA value an enumeration property of a pluggable widget declares no key for (onClickTrigger: triple) is written as given, and the widget ignores ita refusal (MDL-WIDGET08) that names the keys, with nothing writtenexecno: decided by exec against the project; check -p reports it
MDL-V1-WIDGETPROPA property a built-in page element does not take (Caption: on a textbox, Label: on a button, a misspelt key) is accepted and dropped on writea refusal (MDL-WIDGET52), with nothing writtenexecno: decided by exec against the project; check -p reports it

Deprecated spellings (MDL-DEPR*)

88 old spellings mean exactly what their new form means. They warn with their code under every version before the one in the last column, which refuses them.

CodeOld formNew formRewritten by fmt --upgradeRefused from
MDL-DEPR001create or replace …create or modify …yes: replace → modifymdl 2
MDL-DEPR002show …list …yes: show → listmdl 2
MDL-DEPR003$x = <operation>($List, …)$x = <operation> $List …yes: call form to statement form: head/tail $L; filter/find $L by Member = v (when the condition has that shape) or where ; sort $L by …; union/intersect $A with $B; subtract($A, $B) -> subtract $B from $A; equals $A and $B; range($L, o, n) -> range $L offset o limit nmdl 2
MDL-DEPR004$n = <function>($List, …)$n = <function> $List …yes: call form to statement form: count $L; sum|average|minimum|maximum $L by Attr (for $L.Attr) or of ; all|any $L where ; reduce $L from as using mdl 2
MDL-DEPR005row row1 { … } / column Name (…) — a name on an element Mendix stores none forrow { … } / column (…)yes: name out of the element: row row1 { becomes row {mdl 2
MDL-DEPR006call microflow M.F($Param = expr)call microflow M.F(Param = expr)yes: parameter name without its $ (quoted when it is not an identifier or keyword)mdl 2
MDL-DEPR007show page M.P(Param: expr)show page M.P(Param = expr)yes: colon as =: Param: expr -> Param = exprmdl 2
MDL-DEPR008call microflow M.F with (Param = '<expression>')call microflow M.F(Param = <expression>)yes: string list as a list after the callee, each string’s content written as the bare expressionmdl 2
MDL-DEPR009objects [$a, $b] / parameters ['a', 'b']with ({1} = $a, {2} = $b)yes: positional list as numbered placeholders: objects [a, b] -> with ({1} = a, {2} = b)mdl 2
MDL-DEPR020show_page, save_changes, close_page, microflow M.F, …show page, save changes, close page, call microflow M.F, …yes: page action as words: the underscore becomes a space (show_page -> show page, also save_changes, cancel_changes, close_page, create_object, open_link, sign_out, complete_task); delete_object -> delete; microflow M.F / nanoflow M.F -> call microflow M.F / call nanoflow M.Fmdl 2
MDL-DEPR021error '…' / feedback '…' / error_message '…'error message '…'yes: message keyword as error message: not null error '…' (and after unique or required), a validation rule’s feedback '…', and error_message / errormessagemdl 2
MDL-DEPR022delete_behavior …on delete cascade|restrict|set nullyes: delete behaviour as the SQL referential action: delete_behavior cascade / delete_and_references -> on delete cascade; prevent / delete_if_no_references -> on delete restrict; delete_but_keep_references -> on delete set nullmdl 2
MDL-DEPR023type reference_settype ReferenceSetyes: type name as Mendix writes it: reference_set -> ReferenceSetmdl 2
MDL-DEPR024call rest service … returns nonecall rest service … returns nothingyes: none → nothingmdl 2
MDL-DEPR030grant M.Role on M.E (rights) where '[xpath]'grant rights on entity M.E to M.Role where [xpath]yes: rights before on entity, roles after to, and the XPath out of its string: grant R on M.E (read *) where '[A = ''x'']' becomes grant read * on entity M.E to R where [A = 'x']mdl 2
MDL-DEPR031targeting [users|groups] xpath '[xpath]'targeting [users|groups] xpath [xpath]yes: XPath out of its string: xpath '[Name = ''Admin'']' becomes xpath [Name = 'Admin']mdl 2
MDL-DEPR060alter settings runtime Key = value, … / create configuration 'X' Key = value, …alter settings runtime ( Key: value, … ) / create configuration 'X' ( Key: value, … )yes: assignments in parentheses, each = as :mdl 2
MDL-DEPR061alter consumed|published odata service X set Key = value, …alter consumed|published odata service X set ( Key: value, … )yes: assignments in parentheses, each = as :mdl 2
MDL-DEPR062alter styling on page P widget w set Class = 'x', 'Full width' = onalter styling on page P widget w set ( Class: 'x', 'Full width': on )yes: assignments in parentheses, each = as :mdl 2
MDL-DEPR063alter entity M.E set allow_create_change_locally = truealter entity M.E set ( AllowCreateChangeLocally: true )yes: property as create’s list: set ( AllowCreateChangeLocally: <value> )mdl 2
MDL-DEPR064type: Reference / owner: Both / storage: Tabletype Reference / owner Both / storage Tableyes: clause without its colon: type: Reference as type Reference (also owner, storage)mdl 2
MDL-DEPR065alter entity M.E modify attribute A Typealter entity M.E modify attribute A: Typeyes: attribute definition with its colon: A Type as A: Typemdl 2
MDL-DEPR070operation X { Method: get, … }operation X ( Method: get, … )yes: operation’s braces: operation X { … } becomes operation X ( … )mdl 2
MDL-DEPR071tool X { … } / mcp service M.S { … } / knowledge base K { … }tool X ( … ) / mcp service M.S ( … ) / knowledge base K ( … )yes: attachment’s braces: tool X { … } becomes tool X ( … )mdl 2
MDL-DEPR072image collection M.C ( image X from file '…', … )image collection M.C { image X ( File: '…' ) … }yes: image list: the images move into { } without commas, and from file '…' becomes ( File: '…' )mdl 2
MDL-DEPR073message definition collection M.C ( definition D for M.E ( A, M.E_B/M.B ( C ) ) )message definition collection M.C { definition D for M.E { A, M.E_B/M.B { C } } }yes: message trees: each parenthesised definition list and member tree moves into { }mdl 2
MDL-DEPR074alter microflow M.F { insert after $X { … } }alter microflow M.F { insert after $X begin … end; }yes: fragment’s braces: { becomes begin and } becomes endmdl 2
MDL-DEPR080decision '<expression>' / timer '<expression>' / due date '<expression>'decision <expression> / timer <expression> / due date <expression>yes: expression out of its string: decision '$Ctx/Total > 1000' becomes decision $Ctx/Total > 1000mdl 2
MDL-DEPR081Visible: [<expression>] / Editable: [<expression>]Visible: <expression> / Editable: <expression>, each attribute as $currentObject/Attryes: brackets into the expression they store: Visible: [Active] becomes Visible: $currentObject/Activemdl 2
MDL-DEPR082revoke M.Role on M.E [(rights)]revoke rights|all on entity M.E from M.Roleyes: rights (or all when none are listed) before on entity, roles after from: revoke R on M.E (write *) becomes revoke write * on entity M.E from R, revoke R on M.E becomes revoke all on entity M.E from Rmdl 2
MDL-DEPR083Username: $ConstUsername: @Module.Constyes: $Const becomes @<the service's module>.Constmdl 2
MDL-DEPR084Key: Module.ConstKey: @Module.Constyes: @ before the constant’s namemdl 2
MDL-DEPR085alter settings constant 'Module.Const' …alter settings constant @Module.Const …yes: constant’s name out of its string, with @: constant 'M.ApiUrl' becomes constant @M.ApiUrlmdl 2
MDL-DEPR086Variables: ( $name: Type = '<expression>' ) / add variables $name: Type = '<expression>'Variables: ( $name: Type = <expression> ) / add variables $name: Type = <expression>yes: default out of its string: $show: boolean = 'true' becomes $show: boolean = truemdl 2
MDL-DEPR090show page|project security|security matrix|structure|context of …describe page|app security|security matrix|structure|context of …yes: verb as describe: show page X -> describe page X, show project security -> describe app security; the same for list on these formsmdl 2
MDL-DEPR091alter user role R remove module roles (…)alter user role R drop module roles (…)yes: remove → dropmdl 2
MDL-DEPR092alter settings language remove '…' / alter settings workflows remove group '…'alter settings language drop '…' / alter settings workflows drop group '…'yes: remove → dropmdl 2
MDL-DEPR093alter entity E add|rename|modify|drop column …alter entity E add|rename|modify|drop attribute …yes: column → attributemdl 2
MDL-DEPR094rest call get|post|… 'url' …call rest service get|post|… 'url' …yes: statement keyword: rest call becomes call rest servicemdl 2
MDL-DEPR095describe widget <name>describe widget type <name>yes: type after widget: describe widget combobox -> describe widget type comboboxmdl 2
MDL-DEPR096define fragment F as { … }create fragment F as { … }yes: define → createmdl 2
MDL-DEPR100create constant|association|json structure|image collection … comment '…'/** … */ before the statementyes: documentation as a doc comment: the clause is deleted and its text written as /** … */ before the statementmdl 2
MDL-DEPR101set Key = value [on target] / set (Key = value, …) [on target]set (Key: value, …) [on target]yes: each = as :, and the assignments in parentheses when they are notmdl 2
MDL-DEPR102set Key: value [on target]set (Key: value) [on target]yes: assignment in parenthesesmdl 2
MDL-DEPR103drop widget a, bdrop a, byes: drop widget a as drop amdl 2
MDL-DEPR104<workflow activity> comment '…'<workflow activity> caption '…'yes: comment → captionmdl 2
MDL-DEPR105create page|snippet|consumed rest service|… M.N (…, Folder: '…', …)create page|snippet|consumed rest service|… M.N folder '…' (…)yes: folder as a clause: the property is deleted from the list and written as folder '…' after the namemdl 2
MDL-DEPR106Documentation: '…' in a regular expression, task queue or scheduled event/** … */ before the statementyes: documentation as a doc comment: the property is deleted from the list and its text written as /** … */ before the statementmdl 2
MDL-DEPR120Headers: ( 'Name' = value )Headers: ( 'Name': value )yes: header list: 'Name' = value becomes 'Name': valuemdl 2
MDL-DEPR121navigation P menu ( menu item …; menu 'X' ( … ); ) / create menu M.M ( … )navigation P { menu item … menu 'X' { … } } / create menu M.M { … }yes: menu items: menu ( becomes {, a menu’s or sub-menu’s ( ) become { }, and the ; after each item is droppedmdl 2
MDL-DEPR122menu item 'X' page M.P icon I / microflow M.F / sign outmenu item 'X' ( OnClick: show page M.P, Icon: I ) / call microflow M.F / sign outyes: item clauses: page M.P becomes ( OnClick: show page M.P ), microflow M.F OnClick: call microflow M.F, sign out OnClick: sign out, and icon I Icon: Imdl 2
MDL-DEPR123Params: { $P: M.E } / Variables: { $v: Boolean = 'true' }Params: ( $P: M.E ) / Variables: ( $v: Boolean = 'true' )yes: header map: the braces become ( )mdl 2
MDL-DEPR124ContentParams: [{1} = expr]ContentParams: ({1} = expr)yes: template parameters: the brackets become ( )mdl 2
MDL-DEPR125DesignProperties: ['Key': 'Value', 'Group': ['k': on]]DesignProperties: ('Key': 'Value', 'Group': ('k': on))yes: design properties: each bracketed list becomes ( )mdl 2
MDL-DEPR126snippetcall s (Snippet: M.S, Params: {$Asset: $var})snippetcall s (Snippet: M.S, Params: (Asset = $var))yes: snippet call arguments: {$P: $v} becomes (P = $v)mdl 2
MDL-DEPR127database connection M.Db type '…' connection string @M.C … begin query Q sql '…' returns M.E map (c as A); enddatabase connection M.Db ( Type: '…', ConnectionString: @M.C, … ) { query Q ( Sql: '…', Returns: M.E, Map: ( A = c ) ) }yes: clauses become the property list: type Type:, connection string ConnectionString:, host Host:, port Port:, database DatabaseName:, username Username:, password Password:; begin … end becomes { }; a query’s sql, parameter, returns and map become Sql, Parameters, Returns and Map, and column as Attr becomes Attr = columnmdl 2
MDL-DEPR130list image|icon|message definition collection [in M]list image|icon|message definition collections [in M]yes: singular as the plural: collection -> collections after listmdl 2
MDL-DEPR131create|alter|drop|describe|move model M.X / list modelscreate|alter|drop|describe|move ai model M.X / list ai modelsyes: document name: model becomes ai model, models becomes ai modelsmdl 2
MDL-DEPR132create json structure M.J snippet '…'create json structure M.J sample '…'yes: snippet → samplemdl 2
MDL-DEPR133alter app security level …|demo users on|off|guest access on [role R]|off|strict mode on|offalter app security ( SecurityLevel: …, EnableDemoUsers: …, EnableGuestAccess: …, GuestUserRole: R, StrictMode: … )yes: clause as a property list: level production -> ( SecurityLevel: production )mdl 2
MDL-DEPR134create constant M.C ( … ) folder '…' / create snippet M.S (…) folder '…' { … }create constant M.C folder '…' ( … ) / create snippet M.S folder '…' (…) { … }yes: clause moved: folder '…' goes right after the namemdl 2
MDL-DEPR135alter entity|association|enumeration … set comment '…'alter entity|association|enumeration … set documentation '…'yes: comment → documentationmdl 2
MDL-DEPR136create constant M.C type T default v [exposed to client]create constant M.C ( Type: T, DefaultValue: v, ExposedToClient: true )yes: constant properties: type T default v exposed to client becomes ( Type: T, DefaultValue: v, ExposedToClient: true )mdl 2
MDL-DEPR137create demo user 'u' password 'p' [entity M.E] (Role, …)create demo user 'u' ( Password: 'p', Entity: M.E, UserRoles: (Role, …) )yes: demo user properties: password 'p' entity M.E (R1, R2) becomes ( Password: 'p', Entity: M.E, UserRoles: (R1, R2) )mdl 2
MDL-DEPR138create constant M.C … privatecreate constant M.C …yes: constant’s private modifier away: it is deletedmdl 1
MDL-DEPR139create published odata service M.S ( … ) authentication basic, session, microflow M.Fcreate published odata service M.S ( …, Authentication: (basic, session, microflow M.F) )yes: published OData authentication: the trailing authentication m1, m2 clause becomes the last property, Authentication: (m1, m2)mdl 2
MDL-DEPR140alter workflow M.W set display 'x' / set description … / set export level … / set due date … / set overview page … / set parameter $P: M.Ealter workflow M.W { set ( Display: 'x', Description: …, ExportLevel: …, DueDate: …, OverviewPage: …, Parameter: $P: M.E ); }yes: property as set ( Key: value ), inside the statement’s { }mdl 2
MDL-DEPR141alter workflow M.W set activity X page M.P / description … / targeting … / due date …alter workflow M.W { set ( Page: M.P, Description: …, Targeting: …, DueDate: … ) on X; }yes: set activity X <prop> v as set ( Key: v ) on X, inside the statement’s { }mdl 2
MDL-DEPR142alter workflow M.W insert after X <activity>;alter workflow M.W { insert after X { <activity>; } }yes: inserted activity in { }, inside the statement’s { }mdl 2
MDL-DEPR143alter workflow M.W drop activity Xalter workflow M.W { drop X; }yes: drop activity X as drop X, inside the statement’s { }mdl 2
MDL-DEPR144alter workflow M.W replace activity X with <activity>;alter workflow M.W { replace X with { <activity>; } }yes: replace activity X with a; as replace X with { a; }, inside the statement’s { }mdl 2
MDL-DEPR145alter workflow M.W insert outcome 'N' on X { … }alter workflow M.W { insert into X { outcomes 'N' { … } } }yes: insert outcome 'N' on X { … } as insert into X { outcomes 'N' { … } }mdl 2
MDL-DEPR146alter workflow M.W insert path on X { … }alter workflow M.W { insert into X { path { … } } }yes: insert path on X { … } as insert into X { path { … } }mdl 2
MDL-DEPR147alter workflow M.W insert condition 'V' on X { … }alter workflow M.W { insert into X { outcomes 'V' -> { … } } }yes: insert condition 'V' on X { … } as insert into X { outcomes 'V' -> { … } }mdl 2
MDL-DEPR148alter workflow M.W insert boundary event on X <event>alter workflow M.W { insert into X { boundary event <event> } }yes: insert boundary event on X e as insert into X { boundary event e }mdl 2
MDL-DEPR149alter workflow M.W drop outcome 'N' on X / drop condition 'V' on X / drop path 'Path n' on X / drop boundary event on Xalter workflow M.W { drop X outcome 'N'; drop X outcome true; drop X path n; drop X boundary event; }yes: member after the activity it belongs to: drop X outcome 'N', drop X path n, drop X boundary eventmdl 2
MDL-DEPR160dateDateTimeyes: type date as the type it was stored as: DateTimemdl 1
MDL-DEPR161create regular expression M.R ( …, ExportLevel: Public )create regular expression M.R ( …, ExportLevel: API )yes: regular expression’s export level value Public as APImdl 2
MDL-DEPR540on error [without rollback] { … }on error [without rollback] begin … end erroryes: { becomes begin and the closing } becomes end errormdl 2
MDL-DEPR550rest client / rest clientsconsumed rest service / consumed rest servicesyes: document type name: rest client becomes consumed rest service, rest clients consumed rest servicesmdl 2
MDL-DEPR551odata client / odata clientsconsumed odata service / consumed odata servicesyes: document type name: odata client becomes consumed odata service, odata clients consumed odata servicesmdl 2
MDL-DEPR552odata service / odata servicespublished odata service / published odata servicesyes: document type name: odata service becomes published odata servicemdl 2
MDL-DEPR553queue / queuestask queue / task queuesyes: document type name: queue becomes task queue, queues task queuesmdl 2
MDL-DEPR554alter project security …alter app security …yes: security name: project security becomes app securitymdl 2
MDL-DEPR555alter settings model …alter settings runtime …yes: section name: model becomes runtimemdl 2
MDL-DEPR710create user role R (M.A, …) [manage all roles]create user role R ( ModuleRoles: (M.A, …), ManageAllRoles: true, … )yes: user role properties: the role list becomes ModuleRoles: (…) in a ( Key: value ) list, and manage all roles becomes ManageAllRoles: truemdl 2
MDL-DEPR711Headers: ('Authorization': 'Bearer ' + $Token) / ('X-Key': $Key)Headers: ('Authorization': 'Bearer {Token}') / ('X-Key': '{Key}')yes: header value as a template: 'text' + $P becomes 'text{P}'mdl 2
MDL-DEPR720call rest service <method> <url> [header N = v …] [auth basic $u password $p] [body …] [timeout n] returns …call rest service <method> <url> (Headers: ('N': v), Authentication: basic (Username: $u, Password: $p), Body: …, Timeout: n) returns …yes: activity settings as one property list (ADR-0013): header N = v becomes Headers: ('N': v), auth basic $u password $p becomes Authentication: basic (Username: $u, Password: $p), body '…' becomes Body: template '…', body binary|mapping … becomes Body: binary|mapping …, timeout n becomes Timeout: nmdl 2

Data Types

MDL has a type system that maps to the Mendix metamodel attribute types. Every attribute in an entity definition has a type that determines how values are stored and validated.

Type Categories

CategoryTypes
TextString, String(n), HashedString
NumericInteger, Long, Decimal, AutoNumber
LogicalBoolean
TemporalDateTime, Date
BinaryBinary
ReferenceEnumeration(Module.Name)

Quick Example

CREATE PERSISTENT ENTITY Demo.AllTypes (
  ItemNumber: AutoNumber NOT NULL UNIQUE DEFAULT 1,
  Code: String(10) NOT NULL UNIQUE,
  Name: String(200) NOT NULL,
  Description: String(unlimited),
  Counter: Integer DEFAULT 0,
  BigNumber: Long,
  Amount: Decimal DEFAULT 0.00,
  IsActive: Boolean DEFAULT TRUE,
  CreatedAt: DateTime,
  BirthDate: DateTime,
  Attachment: Binary,
  Status: Enumeration(Demo.Status) DEFAULT 'Active'
);

No Implicit Conversions

MDL does not perform implicit type conversions. Types must match exactly when assigning defaults or modifying attributes.

Compatible type changes (when using CREATE OR MODIFY):

  • String(100) to String(200) – increasing length
  • Integer to Long – widening numeric type

Incompatible type changes:

  • String to Integer
  • Boolean to String
  • Any type to AutoNumber (if data exists)

Further Reading

Primitive Types

This page documents each primitive type available for entity attributes in MDL.

String

Variable-length text data.

String              -- Default length (200)
String(n)           -- Specific length (1 to unlimited)
String(unlimited)   -- No length limit

Examples:

Name: String(200)
Description: String(unlimited)
Code: String(10)

Default value format:

Name: String(200) DEFAULT 'Unknown'
Code: String(10) DEFAULT ''

Integer

32-bit signed integer.

Integer

Range: -2,147,483,648 to 2,147,483,647

Quantity: Integer
Age: Integer DEFAULT 0
Priority: Integer NOT NULL DEFAULT 1

Long

64-bit signed integer.

Long

Range: -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807

FileSize: Long
TotalCount: Long DEFAULT 0

Decimal

High-precision decimal number with up to 20 digits total.

Decimal
Price: Decimal
Amount: Decimal DEFAULT 0
TaxRate: Decimal DEFAULT 0.21

Boolean

True/false value. Boolean attributes must have a DEFAULT value (enforced by Mendix Studio Pro).

Boolean DEFAULT TRUE
Boolean DEFAULT FALSE
IsActive: Boolean DEFAULT TRUE
Enabled: Boolean DEFAULT TRUE
Deleted: Boolean DEFAULT FALSE

DateTime

Date and time stored as a UTC timestamp.

DateTime
CreatedAt: DateTime
ModifiedAt: DateTime
ScheduledFor: DateTime

A DateTime is localized (shown in the user’s time zone) by default. For a calendar date that must not shift with the time zone, such as a birth date, add NOT LOCALIZED — see Constraints:

BirthDate: DateTime NOT LOCALIZED

DateTime values include both date and time components. To show only the date, format it on the widget; there is no Date type.

No Date, Float or Currency

Mendix has no date-only attribute type: a date is a DateTime, and showing only its date part is a formatting choice on the widget. Float and Currency were removed in Mendix 7 in favour of Decimal. MDL refuses float and currency with the type to write instead — earlier versions accepted them and silently stored a String.

date was always stored as a DateTime, so in a script without the mdl 1; header it is a deprecated spelling of DateTime: it builds a DateTime and warns MDL-DEPR160, and mxcli fmt --upgrade rewrites it to DateTime. Under mdl 1; it is an error.

AutoNumber

Auto-incrementing integer, typically used for human-readable identifiers. The DEFAULT value specifies the starting number.

AutoNumber
OrderId: AutoNumber NOT NULL UNIQUE DEFAULT 1
CustomerId: AutoNumber

AutoNumber attributes automatically receive the next value on object creation. They are typically combined with NOT NULL and UNIQUE constraints.

Binary

Binary data for files, images, and other non-text content.

Binary
ProfileImage: Binary
Document: Binary
Thumbnail: Binary

File metadata (name, size, MIME type) is stored separately by Mendix. Maximum size is configurable per attribute.

HashedString

Securely hashed string, used for password storage. Values are one-way hashed and cannot be retrieved – comparison is done by hashing the input and comparing hashes.

HashedString
Password: HashedString

Enumeration Reference

References an enumeration type defined with CREATE ENUMERATION. See Enumerations for details.

Enumeration(<Module.EnumerationName>)
Status: Enumeration(Sales.OrderStatus)
Priority: Enumeration(Core.Priority) DEFAULT Core.Priority.Normal
Type: Enumeration(MyModule.ItemType) NOT NULL

Default value format:

-- Fully qualified (preferred)
DEFAULT Module.EnumName.ValueName

-- Legacy string literal form
DEFAULT 'ValueName'

See Also

Constraints

Constraints restrict the values an attribute can hold. They are specified after the type in an attribute definition.

Syntax

<name>: <type> [NOT NULL [ERROR '<message>']] [UNIQUE [ERROR '<message>']] [DEFAULT <value>]

Constraints must appear in this order:

  1. NOT NULL (optionally with ERROR)
  2. UNIQUE (optionally with ERROR)
  3. DEFAULT

NOT NULL

Marks the attribute as required. The value cannot be empty.

Name: String(200) NOT NULL

With a custom error message displayed to the user:

Name: String(200) NOT NULL ERROR MESSAGE 'Name is required'

UNIQUE

Enforces that the value must be unique across all objects of the entity.

Email: String(200) UNIQUE

With a custom error message:

Email: String(200) UNIQUE ERROR MESSAGE 'Email already exists'

DEFAULT

Sets the initial value when a new object is created.

Count: Integer DEFAULT 0
IsActive: Boolean DEFAULT TRUE
Status: Enumeration(Sales.OrderStatus) DEFAULT 'Draft'
Name: String(200) DEFAULT 'Unknown'

Default value syntax varies by type:

TypeDefault SyntaxExamples
StringDEFAULT 'value'DEFAULT '', DEFAULT 'Unknown'
IntegerDEFAULT nDEFAULT 0, DEFAULT -1
LongDEFAULT nDEFAULT 0
DecimalDEFAULT n.nDEFAULT 0, DEFAULT 0.00, DEFAULT 99.99
BooleanDEFAULT TRUE/FALSEDEFAULT TRUE, DEFAULT FALSE
AutoNumberDEFAULT nDEFAULT 1 (starting value)
EnumerationDEFAULT Module.Enum.ValueDEFAULT Shop.Status.Active, DEFAULT 'Pending'

Omitting the DEFAULT clause means no default value is set:

OptionalField: String(200)

LOCALIZED / NOT LOCALIZED (DateTime only)

Studio Pro’s Localize setting on a DateTime attribute (LocalizeDate in the model). A localized DateTime is converted to the user’s time zone when it is shown or entered; a non-localized one is not, which is what a calendar date such as a birth date needs — otherwise it can show as the day before for a user west of UTC.

OrderDate: DateTime,                -- localized (Mendix's default)
BirthDate: DateTime NOT LOCALIZED,  -- not localized
  • LOCALIZED states the default explicitly. It is mostly useful on ALTER ENTITY … MODIFY ATTRIBUTE to switch a non-localized attribute back, the way NULLABLE clears NOT NULL.
  • On a new attribute, no clause means localized.
  • On a rewrite — CREATE OR MODIFY ENTITY or MODIFY ATTRIBUTE — no clause keeps the value already stored (a Studio Pro-authored non-localized attribute stays non-localized); a stated clause wins. When the keep is a stored not localized, the statement prints a Note: naming the attribute and the DateTime localized spelling that changes it.
  • On a view entity the value follows the OQL source attribute and needs no clause; a stated one overrides it, and a mismatch with the source is CE6770.
  • On any type other than DateTime the clause is an error: there is nowhere to store it.
  • DESCRIBE ENTITY prints not localized (right after the type) when the attribute is not localized, and nothing otherwise, so its output round-trips.

The deprecated Date type (MDL-DEPR160) is a localized DateTime. For a date-only value write DateTime NOT LOCALIZED.

Combining Constraints

All three constraints can be used together:

Email: String(200) NOT NULL ERROR MESSAGE 'Email is required'
                   UNIQUE ERROR MESSAGE 'Email already exists'
                   DEFAULT ''

More examples:

CREATE PERSISTENT ENTITY Sales.Product (
  -- Required only
  Name: String(200) NOT NULL,

  -- Required with custom error
  SKU: String(50) NOT NULL ERROR MESSAGE 'SKU is required for all products',

  -- Unique only
  Barcode: String(50) UNIQUE,

  -- Required and unique with custom errors
  ProductCode: String(20) NOT NULL ERROR MESSAGE 'Product code required'
                          UNIQUE ERROR MESSAGE 'Product code must be unique',

  -- Default only
  Quantity: Integer DEFAULT 0,

  -- No constraints
  Description: String(unlimited)
);

Validation Rule Mapping

Under the hood, constraints map to Mendix validation rules:

MDL ConstraintMendix Validation Rule
NOT NULLDomainModels$RequiredRuleInfo
UNIQUEDomainModels$UniqueRuleInfo

LOCALIZED / NOT LOCALIZED is not a validation rule: it is the LocalizeDate property of the attribute’s DomainModels$DateTimeAttributeType.

See Also

Enumerations

Enumerations define a fixed set of named values. They are used as attribute types to restrict a field to a specific list of options.

CREATE ENUMERATION

[/** <documentation> */]
CREATE ENUMERATION <Module>.<Name> (
  <value-name> '<caption>' [, ...]
)

Each value has an identifier (used in code) and a caption (displayed in the UI):

/** Order status enumeration */
CREATE ENUMERATION Sales.OrderStatus (
  Draft 'Draft',
  Pending 'Pending Approval',
  Approved 'Approved',
  Shipped 'Shipped',
  Delivered 'Delivered',
  Cancelled 'Cancelled'
);

Using Enumerations in Attributes

Reference an enumeration type in an attribute definition with Enumeration(Module.Name):

CREATE PERSISTENT ENTITY Sales.Order (
  Status: Enumeration(Sales.OrderStatus) DEFAULT 'Draft',
  Priority: Enumeration(Core.Priority) NOT NULL
);

The default value is the name of the enumeration value (not the caption). The fully qualified form is preferred:

-- Fully qualified (preferred)
Status: Enumeration(Sales.OrderStatus) DEFAULT Sales.OrderStatus.Draft

-- Legacy string literal form
Status: Enumeration(Sales.OrderStatus) DEFAULT 'Draft'

ALTER ENUMERATION

Add, rename, re-caption, or remove values on an existing enumeration:

mdl 1;
-- Add a new value (the CAPTION keyword is required for ALTER, unlike CREATE)
ALTER ENUMERATION Sales.OrderStatus
  ADD VALUE OnHold CAPTION 'On Hold';

-- Rename a value's identifier (the name used in code)
ALTER ENUMERATION Sales.OrderStatus
  RENAME VALUE Draft TO Pending;

-- Change an existing value's display caption in place (the name is unchanged)
ALTER ENUMERATION Sales.OrderStatus
  MODIFY VALUE OnHold CAPTION 'Temporarily On Hold';

-- Remove a value
ALTER ENUMERATION Sales.OrderStatus
  DROP VALUE Draft;

MODIFY VALUE … CAPTION edits the caption without dropping the value, so it works even while the enumeration is referenced by an entity attribute — unlike drop + recreate, which is blocked by the reference. Several actions can be combined in one statement (e.g. RENAME VALUE Draft TO Pending MODIFY VALUE Pending CAPTION 'Pending Review').

DROP ENUMERATION

Remove an enumeration entirely:

DROP ENUMERATION Sales.OrderStatus;

Dropping an enumeration that is still referenced by entity attributes will cause errors in Studio Pro.

See Also

Type Mapping

This page shows how MDL types correspond to Mendix internal types (BSON storage) and the Go SDK types used by modelsdk-go.

MDL to Backend Type Mapping

MDL TypeBSON $TypeGo SDK Type
StringDomainModels$StringAttributeType*StringAttributeType
String(n)DomainModels$StringAttributeType + Length*StringAttributeType{Length: n}
IntegerDomainModels$IntegerAttributeType*IntegerAttributeType
LongDomainModels$LongAttributeType*LongAttributeType
DecimalDomainModels$DecimalAttributeType*DecimalAttributeType
BooleanDomainModels$BooleanAttributeType*BooleanAttributeType
DateTimeDomainModels$DateTimeAttributeType*DateTimeAttributeType
DateDomainModels$DateTimeAttributeType*DateTimeAttributeType
AutoNumberDomainModels$AutoNumberAttributeType*AutoNumberAttributeType
BinaryDomainModels$BinaryAttributeType*BinaryAttributeType
EnumerationDomainModels$EnumerationAttributeType*EnumerationAttributeType
HashedStringDomainModels$HashedStringAttributeType*HashedStringAttributeType

Note that Date and DateTime both map to the same underlying BSON type. The distinction is handled at the UI layer.

Default Value Mapping

Default values are stored in BSON as StoredValue structures:

MDL DefaultBSON Structure
DEFAULT 'text'Value: {$Type: "DomainModels$StoredValue", DefaultValue: "text"}
DEFAULT 123Value: {$Type: "DomainModels$StoredValue", DefaultValue: "123"}
DEFAULT TRUEValue: {$Type: "DomainModels$StoredValue", DefaultValue: "true"}
(calculated)Value: {$Type: "DomainModels$CalculatedValue", Microflow: <id>}

All default values are serialized as strings in the BSON storage, regardless of the attribute type.

See Also

Domain Model

The domain model is the data layer of a Mendix application. It defines the entities (data tables), their attributes (columns), relationships between entities (associations), and inheritance hierarchies (generalizations).

Core Concepts

ConceptMDL StatementDescription
EntityCREATE ENTITYA data structure, similar to a database table
AttributeDefined inside entityA field on an entity, similar to a column
AssociationCREATE ASSOCIATIONA relationship between two entities
GeneralizationEXTENDSInheritance – one entity extends another
IndexINDEX (...)Performance optimization for queries
EnumerationCREATE ENUMERATIONA fixed set of named values for an attribute

Module Scope

Every domain model element belongs to a module. Elements are always referenced by their qualified name in Module.Element format:

Sales.Customer           -- entity
Sales.OrderStatus        -- enumeration
Sales.Order_Customer     -- association

Complete Example

This example creates a small Sales domain model with related entities, an enumeration, and an association:

mdl 1;
-- Enumeration for order statuses
CREATE ENUMERATION Sales.OrderStatus (
  Draft 'Draft',
  Pending 'Pending',
  Confirmed 'Confirmed',
  Shipped 'Shipped',
  Delivered 'Delivered',
  Cancelled 'Cancelled'
);

-- Customer entity
/** Customer master data */
@Position(100, 100)
CREATE PERSISTENT ENTITY Sales.Customer (
  CustomerId: AutoNumber NOT NULL UNIQUE DEFAULT 1,
  Name: String(200) NOT NULL ERROR MESSAGE 'Customer name is required',
  Email: String(200) UNIQUE ERROR MESSAGE 'Email already registered',
  Phone: String(50),
  IsActive: Boolean DEFAULT TRUE,
  CreatedAt: DateTime
)
INDEX (Name)
INDEX (Email);

-- Order entity
/** Sales order */
@Position(300, 100)
CREATE PERSISTENT ENTITY Sales.Order (
  OrderId: AutoNumber NOT NULL UNIQUE DEFAULT 1,
  OrderNumber: String(50) NOT NULL UNIQUE,
  OrderDate: DateTime NOT NULL,
  TotalAmount: Decimal DEFAULT 0,
  Status: Enumeration(Sales.OrderStatus) DEFAULT 'Draft',
  Notes: String(unlimited)
)
INDEX (OrderNumber)
INDEX (OrderDate DESC);

-- Association: Order belongs to Customer
CREATE ASSOCIATION Sales.Order_Customer
  FROM Sales.Customer
  TO Sales.Order
  TYPE Reference
  OWNER Default
  ON DELETE SET NULL;

Further Reading

Entities

Entities are the primary data structures in a Mendix domain model. Each entity corresponds to a database table (for persistent entities) or an in-memory object (for non-persistent entities).

Entity Types

TypeMDL KeywordDescription
PersistentPERSISTENTStored in the database with a corresponding table
Non-PersistentNON-PERSISTENTIn-memory only, scoped to the user session
ViewVIEWBased on an OQL query, read-only
ExternalEXTERNALFrom an external data source (OData, etc.)

CREATE ENTITY

[/** <documentation> */]
[@Position(<x>, <y>)]
CREATE [OR MODIFY] <entity-type> ENTITY <Module>.<Name> (
  <attribute-definitions>
)
[INDEX (<column-list>)]
[ON BEFORE|AFTER CREATE|COMMIT|DELETE|ROLLBACK CALL <Module>.<Microflow> [RAISE ERROR]]
[;|/]

Persistent Entity

The most common type. Data is stored in the application database:

/** Customer master data */
@Position(100, 200)
CREATE PERSISTENT ENTITY Sales.Customer (
  /** Auto-incrementing unique identifier */
  CustomerId: AutoNumber NOT NULL UNIQUE DEFAULT 1,
  /** Full legal name of the customer */
  Name: String(200) NOT NULL ERROR MESSAGE 'Name is required',
  /** Primary contact email address */
  Email: String(200) UNIQUE ERROR MESSAGE 'Email must be unique',
  /** Current account balance */
  Balance: Decimal DEFAULT 0,
  /** Whether the account is active */
  IsActive: Boolean DEFAULT TRUE,
  /** Timestamp of account creation */
  CreatedDate: AutoCreatedDate,
  /** Current lifecycle status */
  Status: Enumeration(Sales.CustomerStatus) DEFAULT 'Active'
)
INDEX (Name)
INDEX (Email);

Non-Persistent Entity

Used for helper objects, filter parameters, and UI state that does not need database storage:

CREATE NON-PERSISTENT ENTITY Sales.CustomerFilter (
  SearchName: String(200),
  MinBalance: Decimal,
  MaxBalance: Decimal
);

View Entity

Defined by an OQL query:

CREATE VIEW ENTITY Reports.CustomerSummary (
  CustomerName: String,
  TotalOrders: Integer,
  TotalAmount: Decimal
) AS
  SELECT
    c.Name AS CustomerName,
    COUNT(o.OrderId) AS TotalOrders,
    SUM(o.Amount) AS TotalAmount
  FROM Sales.Customer c
  LEFT JOIN Sales.Order o ON o.Customer = c
  GROUP BY c.Name;

“Read-only” is a common shorthand for view entities, but it conflates three separate things — and only two of them are true:

  • No storage. A view entity has no database table, and it is not a database view either — after deploy, Postgres has no view object for it. The runtime translates the OQL and issues it per query.
  • No write-back on commit. Committing a view-entity object does nothing; to persist a change you write through the source entity (retrieve it, change it, commit it).
  • Not immutable. A view row is editable in memory — it behaves like a non-persistent object, so a form can bind to it and change its attributes. This makes a view entity a legitimate backing for an editable screen (populate the form with one pushed-down query, write back through the source in the save flow), not only a read-only report.

Gotcha: each declared attribute type must match its source column exactly. A String(60) attribute over a String(100) source column fails the build with CE6770 "View Entity is out of sync with the OQL Query." — widen the attribute to match. (mxbuild genuinely type-checks the OQL, so a clean build is real evidence, not just a syntax pass.)

CREATE OR MODIFY

Creates the entity if it does not exist, or updates it if it does. New attributes are added; existing attributes are preserved:

CREATE OR MODIFY PERSISTENT ENTITY Sales.Customer (
  CustomerId: AutoNumber NOT NULL UNIQUE DEFAULT 1,
  Name: String(200) NOT NULL,
  Email: String(200),
  Phone: String(50)  -- new attribute added on modify
);

System Attributes (Auditing)

Persistent entities can track who created/modified objects and when. Declare them as regular attributes using pseudo-types (like AutoNumber):

CREATE PERSISTENT ENTITY Sales.Order (
  OrderNumber: AutoNumber DEFAULT 1,
  TotalAmount: Decimal,
  Owner: AutoOwner,
  ChangedBy: AutoChangedBy,
  CreatedDate: AutoCreatedDate,
  ChangedDate: AutoChangedDate
);
Pseudo-TypeSystem AttributeSet When
AutoOwnerSystem.owner (→ System.User)Object created
AutoChangedBySystem.changedBy (→ System.User)Every commit
AutoCreatedDateCreatedDate (DateTime)Object created
AutoChangedDateChangedDate (DateTime)Every commit

Toggle on existing entities with ALTER ENTITY:

mdl 1;
ALTER ENTITY Sales.Order ADD ATTRIBUTE Owner: AutoOwner;
ALTER ENTITY Sales.Order DROP ATTRIBUTE ChangedDate;

Annotations

@Position

Controls where the entity appears in the domain model diagram:

@Position(100, 200)
CREATE PERSISTENT ENTITY Sales.Customer ( ... );

Documentation

A /** ... */ comment before the entity or before an individual attribute becomes its documentation in Studio Pro:

/** Customer master data.
 *  Stores both active and inactive customers.
 */
@Position(100, 200)
CREATE PERSISTENT ENTITY Sales.Customer (
  /** Auto-incrementing unique identifier */
  CustomerId: AutoNumber NOT NULL UNIQUE DEFAULT 1,

  /** Full legal name of the customer */
  Name: String(200) NOT NULL ERROR MESSAGE 'Name is required',

  /** Primary contact email address */
  Email: String(200) UNIQUE ERROR MESSAGE 'Email must be unique',

  /** Current account balance in the base currency */
  Balance: Decimal DEFAULT 0,

  /** Whether the customer account is active */
  IsActive: Boolean DEFAULT TRUE,

  /** Timestamp of account creation */
  CreatedDate: AutoCreatedDate,

  /** Current lifecycle status */
  Status: Enumeration(Sales.CustomerStatus) DEFAULT 'Active'
)
INDEX (Name)
INDEX (Email);

Attribute-level documentation appears in Studio Pro when hovering over the attribute in the domain model.

Entity Event Handlers

Persistent entities can have microflow event handlers that run before or after Create, Commit, Delete, or Rollback operations. The optional RAISE ERROR clause makes the handler act as a validation microflow — if it returns false, the operation is aborted.

CREATE PERSISTENT ENTITY Sales.Order (
  Total: Decimal,
  Status: String(50)
)
ON BEFORE COMMIT CALL Sales.ACT_ValidateOrder RAISE ERROR
ON AFTER CREATE CALL Sales.ACT_InitDefaults;

Event handlers can also be added or removed via ALTER ENTITY:

mdl 1;
-- Add a handler to an existing entity
ALTER ENTITY Sales.Order
  ADD EVENT HANDLER ON BEFORE DELETE CALL Sales.ACT_CheckCanDelete RAISE ERROR;

-- Remove a handler (CALL names which, when the event has several)
ALTER ENTITY Sales.Order
  DROP EVENT HANDLER ON BEFORE COMMIT CALL Sales.ACT_ValidateOrder;

Moments: BEFORE, AFTER Events: CREATE, COMMIT, DELETE, ROLLBACK

Each (Moment, Event) combination supports one handler per entity. The microflow must exist in the project (validated at execution time).

DROP ENTITY

Removes an entity from the domain model:

DROP ENTITY Sales.Customer;

See Also

Attributes and Validation Rules

Attributes define the fields on an entity. Each attribute has a name, a type, and optional constraints.

Attribute Syntax

[/** <documentation> */]
<name>: <type> [NOT NULL [ERROR '<message>']] [UNIQUE [ERROR '<message>']] [DEFAULT <value>]
ComponentDescription
NameAttribute identifier (follows identifier rules)
TypeOne of the primitive types or an enumeration reference
NOT NULLValue is required
UNIQUEValue must be unique across all objects
DEFAULTInitial value on object creation
/** ... */Documentation comment

Attribute Ordering

Attributes are listed in order, separated by commas. The last attribute has no trailing comma:

CREATE PERSISTENT ENTITY Module.Entity (
  FirstAttr: String(200),      -- comma after
  SecondAttr: Integer,         -- comma after
  LastAttr: Boolean DEFAULT FALSE  -- no comma
);

Validation Rules

Validation rules are expressed as attribute constraints. When validation fails, Mendix displays the error message (if provided) or a default message.

ValidationMDL SyntaxDescription
RequiredNOT NULLAttribute must have a value
Required with messageNOT NULL ERROR MESSAGE 'message'Custom error message on empty
UniqueUNIQUEValue must be unique across all objects
Unique with messageUNIQUE ERROR MESSAGE 'message'Custom error message on duplicate

Example with Validation

CREATE PERSISTENT ENTITY Sales.Product (
  -- Required only
  Name: String(200) NOT NULL,

  -- Required with custom error
  SKU: String(50) NOT NULL ERROR MESSAGE 'SKU is required for all products',

  -- Unique only
  Barcode: String(50) UNIQUE,

  -- Required and unique with custom errors
  ProductCode: String(20) NOT NULL ERROR MESSAGE 'Product code required'
                          UNIQUE ERROR MESSAGE 'Product code must be unique',

  -- Optional field (no validation)
  Description: String(unlimited)
);

Documentation Comments

Attach documentation to individual attributes with /** ... */:

CREATE PERSISTENT ENTITY Sales.Customer (
  /** Unique customer identifier, auto-generated */
  CustomerId: AutoNumber NOT NULL UNIQUE DEFAULT 1,

  /** Full legal name of the customer */
  Name: String(200) NOT NULL,

  /** Primary contact email address */
  Email: String(200) UNIQUE
);

CALCULATED Attributes

An attribute can be marked as calculated, meaning its value is computed by a microflow rather than stored:

FullName: String(400) CALCULATED

Calculated attributes use a CALCULATED BY clause to specify the source microflow. See the MDL quick reference for details.

See Also

Associations

Associations define relationships between entities. They determine how objects reference each other and control behavior on deletion.

Association Types

TypeMDL KeywordCardinalityDescription
ReferenceReferenceMany-to-OneMany objects on the FROM side reference one object on the TO side
ReferenceSetReferenceSetMany-to-ManyObjects on both sides can reference multiple objects

CREATE ASSOCIATION

[/** <documentation> */]
CREATE ASSOCIATION <Module>.<Name>
  FROM <ParentEntity>
  TO <ChildEntity>
  [TYPE <Reference|ReferenceSet>]
  [OWNER <Default|Both|Parent|Child>]
  [STORAGE <COLUMN|TABLE>]
  [ON DELETE <CASCADE|RESTRICT|SET NULL> [ERROR MESSAGE '<text>']]

Every clause after TO is optional. Left out, they mean TYPE Reference OWNER Default STORAGE COLUMN ON DELETE SET NULL, and DESCRIBE ASSOCIATION prints only the clauses that differ from that. STORAGE TABLE is therefore always printed: re-running a description without it would store the association as a column.

Reference (Many-to-One)

A Reference association means each object on the FROM side can reference one object on the TO side. Multiple FROM objects can reference the same TO object.

/** Order belongs to Customer (many-to-one) */
CREATE ASSOCIATION Sales.Order_Customer
  FROM Sales.Customer
  TO Sales.Order
  TYPE Reference
  OWNER Default
  ON DELETE SET NULL;

ReferenceSet (Many-to-Many)

A ReferenceSet association allows multiple objects on both sides to reference each other:

/** Order has many Products (many-to-many) */
CREATE ASSOCIATION Sales.Order_Product
  FROM Sales.Order
  TO Sales.Product
  TYPE ReferenceSet
  OWNER Both;

Owner Options

The owner determines which side of the association can modify the reference.

OwnerDescription
DefaultChild (FROM side) owns the association
BothBoth sides can modify the association
ParentOnly parent (TO side) can modify
ChildOnly child (FROM side) can modify

Delete Behavior

Controls what happens when an associated object is deleted.

BehaviorMDL KeywordDescription
Keep referencesON DELETE SET NULLDelete the object, set references to null (the default)
CascadeON DELETE CASCADEDelete associated objects as well
RestrictON DELETE RESTRICT [ERROR MESSAGE '…']Refuse the delete while references exist

The older DELETE_BEHAVIOR <behavior> clause still parses with the same meaning, but is deprecated (MDL-DEPR022).

/** Invoice must be deleted with Order */
CREATE ASSOCIATION Sales.Order_Invoice
  FROM Sales.Order
  TO Sales.Invoice
  TYPE Reference
  ON DELETE CASCADE;

Naming Convention

Association names typically follow the pattern Module.FromEntity_ToEntity:

Sales.Order_Customer       -- Order references Customer
Sales.Order_Product        -- Order references Product
Sales.Order_Invoice        -- Order references Invoice

DROP ASSOCIATION

Remove an association:

DROP ASSOCIATION Sales.Order_Customer;

See Also

  • Domain Model – overview of domain model concepts
  • Entities – the entities that associations connect
  • Generalization – inheritance (a different kind of relationship)

Generalization

Generalization (inheritance) allows an entity to extend another entity, inheriting all of its attributes and associations. The child entity can add its own attributes on top.

Syntax

CREATE PERSISTENT ENTITY <Module>.<Name>
  EXTENDS <ParentEntity>
(
  <additional-attributes>
);

Both EXTENDS (preferred) and GENERALIZATION (legacy) keywords are supported.

Examples

Extending System.User

The most common use of generalization is creating an application-specific user entity:

/** Employee extends User with additional fields */
CREATE PERSISTENT ENTITY HR.Employee EXTENDS System.User (
  EmployeeNumber: String(20) NOT NULL UNIQUE,
  Department: String(100),
  HireDate: DateTime
);

The HR.Employee entity inherits all attributes from System.User (Name, Password, etc.) and adds EmployeeNumber, Department, and HireDate.

Extending System.Image

For entities that store images:

/** Product photo entity */
CREATE PERSISTENT ENTITY Catalog.ProductPhoto EXTENDS System.Image (
  Caption: String(200),
  SortOrder: Integer DEFAULT 0
);

Extending System.FileDocument

For entities that store file attachments:

/** File attachment entity */
CREATE PERSISTENT ENTITY Docs.Attachment EXTENDS System.FileDocument (
  Description: String(500)
);

Common System Generalizations

Parent EntityPurpose
System.UserUser accounts with authentication
System.FileDocumentFile storage (name, size, content)
System.ImageImage storage (extends FileDocument with dimensions)

Inheritance Rules

  • A persistent entity can extend another persistent entity
  • The child entity inherits all attributes and associations of the parent
  • The child entity can add new attributes but cannot remove inherited ones
  • Associations to the parent entity also apply to child objects
  • Database queries on the parent entity include child objects

See Also

Indexes

Indexes improve query performance for frequently searched or sorted attributes. They are defined after the attribute list in an entity definition.

Syntax

INDEX (<column> [ASC|DESC] [, <column> [ASC|DESC] ...])

Indexes are placed after the closing parenthesis of the attribute list:

CREATE PERSISTENT ENTITY Sales.Order (
  OrderId: AutoNumber NOT NULL UNIQUE DEFAULT 1,
  OrderNumber: String(50) NOT NULL,
  CustomerId: Long,
  OrderDate: DateTime,
  Status: Enumeration(Sales.OrderStatus)
)
-- Single column index
INDEX (OrderNumber)
-- Composite index with sort direction
INDEX (CustomerId, OrderDate DESC)
-- Another single column index
INDEX (Status);

Sort Direction

Each column in an index can specify a sort direction:

DirectionKeywordDefault
AscendingASCYes (default)
DescendingDESCNo
INDEX (Name)                    -- ascending (default)
INDEX (Name ASC)                -- explicit ascending
INDEX (CreatedAt DESC)          -- descending
INDEX (Name, CreatedAt DESC)    -- mixed: Name ascending, CreatedAt descending

Adding and Removing Indexes

Use ALTER ENTITY to add or remove indexes on existing entities:

mdl 1;
-- Add a named index
ALTER ENTITY Sales.Customer
  ADD INDEX idx_customer_email (Email);

-- Remove an index by name
ALTER ENTITY Sales.Customer
  DROP INDEX idx_customer_email;

Guidelines

  1. Primary lookups – index columns used in WHERE clauses
  2. Foreign keys – index columns used in association joins
  3. Sorting – index columns used in ORDER BY clauses
  4. Composite order – put high-selectivity columns first in composite indexes

See Also

  • Entities – entity definitions that contain indexes
  • ALTER ENTITY – adding/removing indexes on existing entities

ALTER ENTITY

ALTER ENTITY modifies an existing entity’s attributes, indexes, or documentation without recreating it. This is useful for incremental changes to entities that already contain data.

Each ALTER ENTITY statement performs one action — add, drop, modify, or rename a single attribute; add or drop one index; etc. Apply several changes with several statements.

ADD Attributes

Add a new attribute with ADD ATTRIBUTE:

ALTER ENTITY Sales.Customer ADD ATTRIBUTE Phone: String(50);

New attributes support the same constraints as in CREATE ENTITY. Add several with one statement each:

mdl 1;
ALTER ENTITY Sales.Customer ADD ATTRIBUTE LoyaltyPoints: Integer DEFAULT 0;
ALTER ENTITY Sales.Customer ADD ATTRIBUTE MemberSince: DateTime NOT NULL;

DROP Attributes

Remove an attribute with DROP ATTRIBUTE:

ALTER ENTITY Sales.Customer DROP ATTRIBUTE Notes;

Drop several with one statement each:

mdl 1;
ALTER ENTITY Sales.Customer DROP ATTRIBUTE Notes;
ALTER ENTITY Sales.Customer DROP ATTRIBUTE TempField;
ALTER ENTITY Sales.Customer DROP ATTRIBUTE OldStatus;

MODIFY Attributes

Change the type or constraints of an existing attribute with MODIFY ATTRIBUTE:

ALTER ENTITY Sales.Customer MODIFY ATTRIBUTE Name: String(400) NOT NULL;

The type is not optional. MODIFY ATTRIBUTE always parses a type, and its type slot accepts a bare qualified name (an entity or enumeration reference), so a clause written where the type belongs is read as the type:

mdl 1;
-- WRONG: `SET` is read as the type name, not as a keyword
ALTER ENTITY Sales.Customer MODIFY ATTRIBUTE Discount: SET DEFAULT 0;

-- Right: restate the type
ALTER ENTITY Sales.Customer MODIFY ATTRIBUTE Discount: Decimal DEFAULT 0;

mxcli refuses the first form and names the alternatives. Before it did, that statement silently rewrote the attribute to an enumeration and produced a project Mendix could not open (#910).

Clearing a default

DROP DEFAULT removes a default value without touching the type:

ALTER ENTITY Sales.Customer DROP DEFAULT ON ATTRIBUTE Discount;

Clearing a default that is already absent is a no-op, not an error. A calculated attribute is refused rather than silently converted to a plain stored one — that is a different change.

DateTime localization

A DateTime that stays a DateTime keeps its stored Localize setting unless the statement states one, so restating the type does not undo a Studio Pro choice. NOT LOCALIZED and LOCALIZED switch it explicitly:

mdl 1;
ALTER ENTITY Sales.Customer MODIFY ATTRIBUTE BirthDate: DateTime NOT LOCALIZED;
ALTER ENTITY Sales.Customer MODIFY ATTRIBUTE BirthDate: DateTime LOCALIZED;

See Constraints.

RENAME Attributes

Rename an attribute with RENAME ATTRIBUTE:

ALTER ENTITY Sales.Customer RENAME ATTRIBUTE Phone TO PhoneNumber;

Every reference stored as a reference follows the rename — microflow create and change members, page attribute widgets, and the entity’s own validation and access rules — and the command reports how many documents it updated.

XPath constraints follow too — [Phone = '06-1234'] and paths that reach the entity through an association alike — because a constraint’s target entity is known from where it is stored. Another entity’s attribute of the same name is left alone, and any constraint that cannot be resolved is reported rather than rewritten.

Microflow expressions ($Customer/Phone) are not rewritten: a bare name there is only resolvable from the type of what precedes it. mx check reports those as CE0117, so build after renaming an attribute used in one.

ADD INDEX

Add an index to the entity (the index name is optional):

ALTER ENTITY Sales.Customer ADD INDEX (Email);

Composite indexes, with optional sort direction:

ALTER ENTITY Sales.Customer ADD INDEX (Name, CreatedAt DESC);

DROP INDEX

Remove an index by name:

ALTER ENTITY Sales.Customer DROP INDEX idx_customer_email;

SET DOCUMENTATION

Update the entity’s documentation text:

ALTER ENTITY Sales.Customer
  SET DOCUMENTATION 'Customer master data for the Sales module';

ADD/DROP System Attributes

System attributes use the same ADD ATTRIBUTE / DROP ATTRIBUTE syntax as regular attributes:

mdl 1;
-- Add system attributes
ALTER ENTITY Sales.Order ADD ATTRIBUTE Owner: AutoOwner;
ALTER ENTITY Sales.Order ADD ATTRIBUTE ChangedBy: AutoChangedBy;
ALTER ENTITY Sales.Order ADD ATTRIBUTE CreatedDate: AutoCreatedDate;
ALTER ENTITY Sales.Order ADD ATTRIBUTE ChangedDate: AutoChangedDate;

-- Drop system attributes (by name)
ALTER ENTITY Sales.Order DROP ATTRIBUTE Owner;
ALTER ENTITY Sales.Order DROP ATTRIBUTE ChangedDate;

ADD/DROP EVENT HANDLER

Register microflows to run before or after entity operations:

mdl 1;
-- Before commit: validates and can abort (RAISE ERROR)
ALTER ENTITY Sales.Order
  ADD EVENT HANDLER ON BEFORE COMMIT CALL Sales.ValidateOrder($currentObject) RAISE ERROR;

-- After commit: runs after successful commit (no RAISE ERROR)
ALTER ENTITY Sales.Order
  ADD EVENT HANDLER ON AFTER COMMIT CALL Sales.LogOrderChange($currentObject);

-- Without passing the entity object
ALTER ENTITY Sales.Order
  ADD EVENT HANDLER ON AFTER CREATE CALL Sales.NotifyNewOrder();

-- A second handler for the same event runs as well
ALTER ENTITY Sales.Order
  ADD EVENT HANDLER ON BEFORE COMMIT CALL Sales.CheckCredit($currentObject) RAISE ERROR;

-- Remove an event handler: CALL names which one
ALTER ENTITY Sales.Order
  DROP EVENT HANDLER ON BEFORE COMMIT CALL Sales.CheckCredit;

An entity may have several handlers for one event. A handler is identified by its moment, event and microflow: ADD EVENT HANDLER with another microflow adds a second one, and only the same microflow again is a duplicate (an error, or skipped with IF NOT EXISTS). DROP EVENT HANDLER ON BEFORE COMMIT without CALL works while the entity has one Before Commit handler; when it has several, it is refused and the error names them.

MomentReturnsRAISE ERRORUse case
BEFOREBooleanYes — aborts on falseValidation, permission checks
AFTERVoidNoLogging, notifications, side effects

Events: CREATE, COMMIT, DELETE, ROLLBACK

Parameter: ($currentObject) passes the entity to the microflow, () does not.

Syntax Summary

ALTER ENTITY <Module>.<Entity> ADD ATTRIBUTE <name>: <type> [constraints];

ALTER ENTITY <Module>.<Entity> DROP ATTRIBUTE <name>;

ALTER ENTITY <Module>.<Entity> MODIFY ATTRIBUTE <name>: <type> [constraints];
ALTER ENTITY <Module>.<Entity> DROP DEFAULT ON ATTRIBUTE <name>;

ALTER ENTITY <Module>.<Entity> RENAME ATTRIBUTE <old-name> TO <new-name>;

ALTER ENTITY <Module>.<Entity> ADD INDEX [<name>] (<column> [ASC|DESC] [, ...]);

ALTER ENTITY <Module>.<Entity> DROP INDEX <index-name>;

ALTER ENTITY <Module>.<Entity> SET DOCUMENTATION '<text>';

ALTER ENTITY <Module>.<Entity> SET POSITION (<x>, <y>);

See Also

Microflows and Nanoflows

Microflows and nanoflows are the primary logic constructs in Mendix applications. They define executable sequences of activities – retrieving data, creating objects, calling services, showing pages, and more. In MDL, you create and manage them with declarative SQL-like syntax.

What is a Microflow?

A microflow is a server-side logic flow. It executes on the Mendix runtime, has access to the database, can call external services, and supports transactions with error handling. Microflows are the workhorse of Mendix application logic.

Typical uses:

  • CRUD operations (create, read, update, delete objects)
  • Validation logic before saving
  • Calling external web services or Java actions
  • Batch processing and data transformations
  • Scheduled event handlers

What is a Nanoflow?

A nanoflow is a client-side logic flow. It executes in the user’s browser (or on mobile devices), providing fast response times without server round-trips. Nanoflows have a more limited set of available activities – they cannot access the database directly or use Java actions.

Typical uses:

  • Client-side validation
  • Showing/closing pages
  • Changing objects already in memory
  • Calling nanoflows or microflows

Basic Syntax

Both microflows and nanoflows follow the same structural pattern in MDL:

CREATE MICROFLOW Module.ActionName
FOLDER 'OptionalFolder'
BEGIN
  -- parameters, variables, activities, return
END;
CREATE NANOFLOW Module.ClientAction
BEGIN
  -- activities (client-side subset)
END;

SHOW and DESCRIBE

List microflows and nanoflows in the project:

LIST MICROFLOWS;
LIST MICROFLOWS IN MyModule;
LIST NANOFLOWS;
LIST NANOFLOWS IN MyModule;

View the full MDL definition of an existing microflow or nanoflow (round-trippable output):

DESCRIBE MICROFLOW MyModule.ACT_CreateOrder;
DESCRIBE NANOFLOW MyModule.NAV_ShowDetails;

DROP

Remove a microflow or nanoflow:

mdl 1;
DROP MICROFLOW MyModule.ACT_CreateOrder;
DROP NANOFLOW MyModule.NAV_ShowDetails;

OR REPLACE

Use CREATE OR REPLACE to overwrite an existing microflow or nanoflow if it already exists, or create it if it does not:

CREATE OR REPLACE MICROFLOW MyModule.ACT_CreateOrder
BEGIN
  -- updated logic
END;

Document properties

Four microflow properties live in the header, between the signature and begin:

CREATE OR MODIFY MICROFLOW Shop.ACT_ShowOrder ($Order: Shop.Order, $Tab: String)
URL 'order/{Order}'
URL SEARCH PARAMETERS ($Tab)
EXPORT LEVEL API
DISALLOW CONCURRENT EXECUTION ERROR MESSAGE 'This order is already being processed'
BEGIN
  -- ...
END;
ClauseSetsClear with
URL 'order/{Order}'the deep link (Mendix 10.6+)DROP URL
URL SEARCH PARAMETERS ($Tab)parameters passed as query argumentsDROP URL
EXPORT LEVEL APIthe module’s public surface on exportEXPORT LEVEL HIDDEN
DISALLOW CONCURRENT EXECUTION ERROR MESSAGE '…'what a second caller getsALLOW CONCURRENT EXECUTION

ERROR MICROFLOW Module.Name is the other form of the last one.

An omitted clause preserves what is stored. A rewrite that only changes the body leaves every one of them alone — the same rule as @excluded and @applyentityaccess. Clearing is always explicit.

Studio Pro’s Mark as used has no clause; it is carried across a rewrite and cannot be set from MDL.

Rules checked before the write

These are Mendix constraints that otherwise surface only when you build:

  • Every {Name} must name a parameter of this microflow (MDL-MF01). A segment may hold an attribute path — {Customer/Name} binds the Customer parameter — so the leading identifier is what matches.
  • A parameter used in the path may not also be a search parameter (MDL-MF02, mxbuild’s CE5612). The two sets are disjoint.
  • DISALLOW CONCURRENT EXECUTION needs a message or a microflow (MDL-MF03, CE4899).
  • Two microflows may not share a URL (CE0570). Needs -p, since a script alone cannot see the other microflows.

Copying a microflow

DESCRIBE emits all four clauses, so describe → rename → exec copies them faithfully — that is what the clauses are for. Give the copy a different URL, or the build fails with CE0570.

DROP followed by CREATE is a new document and keeps nothing unless the script restates it.

An error message is translatable and MDL states one language. Rewriting the same microflow keeps the others; a copy gets only the one shown, and DESCRIBE says so on the line.

Folder Organization

Place microflows and nanoflows into folders for project organization:

CREATE MICROFLOW Sales.ACT_CreateOrder
FOLDER 'Orders'
BEGIN
  -- ...
END;

Move an existing microflow to a different folder:

mdl 1;
MOVE MICROFLOW Sales.ACT_CreateOrder TO FOLDER 'Orders/Actions';
MOVE NANOFLOW Sales.NAV_ShowDetail TO FOLDER 'Navigation';

Nested folders use / as the separator. Missing folders are created automatically.

Quick Example

CREATE MICROFLOW Sales.ACT_CreateOrder
FOLDER 'Orders'
BEGIN
  DECLARE $Order Sales.Order;
  $Order = CREATE Sales.Order (
    OrderDate = [%CurrentDateTime%],
    Status = 'Draft'
  );
  COMMIT $Order;
  SHOW PAGE Sales.Order_Edit (Order = $Order);
  RETURN $Order;
END;

This microflow creates a new Order object with default values, commits it to the database, opens the edit page, and returns the new order.

Next Steps

  • Structure – parameters, variables, return types, and the full CREATE MICROFLOW syntax
  • Activity Types – all available activities (RETRIEVE, CREATE, CHANGE, COMMIT, DELETE, CALL, LOG, etc.)
  • Control Flow – IF/ELSE, LOOP, WHILE, error handling
  • Expressions – expression syntax for conditions and calculations
  • Nanoflows vs Microflows – differences and nanoflow-specific syntax
  • Common Patterns – CRUD, validation, batch processing, and anti-patterns to avoid

Structure

This page covers the structural elements of a microflow definition: the CREATE MICROFLOW syntax, parameters, variable declarations, return values, and annotations.

Full CREATE MICROFLOW Syntax

CREATE [OR REPLACE] MICROFLOW <Module.Name>
  [FOLDER '<path>']
BEGIN
  [<declarations>]
  [<activities>]
  [RETURN <value>;]
END;

The OR REPLACE modifier overwrites an existing microflow of the same name. The FOLDER clause organizes the microflow within the module’s folder structure.

Parameters

Parameters are declared with DECLARE at the top of the BEGIN...END block. They define the inputs to the microflow.

Primitive Parameters

DECLARE $Name String;
DECLARE $Count Integer;
DECLARE $IsActive Boolean;
DECLARE $Amount Decimal;
DECLARE $StartDate DateTime;

Supported primitive types: String, Integer, Long, Boolean, Decimal, DateTime.

Entity Parameters

Entity parameters receive a single object:

DECLARE $Customer MyModule.Customer;

Important: Do not use = empty or AS with entity declarations. The correct syntax is simply DECLARE $Var Module.Entity;.

List Parameters

List parameters receive a list of objects:

DECLARE $Orders List of Sales.Order = empty;

The = empty initializer creates an empty list. This is required for list declarations.

Parameter vs. Local Variable

In MDL, all DECLARE statements at the top of a microflow are treated as parameters. Variables created by activities (such as $Var = CREATE ... or RETRIEVE $Var ...) are local variables. If you need a local variable with a default value, use DECLARE followed by SET:

DECLARE $Counter Integer = 0;
SET $Counter = 10;

Variables and Assignment

Variable Declaration

DECLARE $Message String = 'Hello';
DECLARE $Total Decimal = 0;
DECLARE $Found Boolean = false;

Assignment with SET

Change the value of an already-declared variable:

SET $Counter = $Counter + 1;
SET $FullName = $FirstName + ' ' + $LastName;
SET $IsValid = $Amount > 0;

The variable must be declared before it can be assigned.

Variables from Activities

Activities like CREATE and RETRIEVE produce result variables:

$Order = CREATE Sales.Order (Status = 'New');
RETRIEVE $Customer FROM Sales.Customer WHERE Email = $Email FIRST;
$Result = CALL MICROFLOW Sales.CalculateTotal (Order = $Order);

Return Values

Every flow path should end with a RETURN statement. The return type is inferred from the returned value.

Returning a Primitive

RETURN true;
RETURN $Total;
RETURN 'Success';

Returning an Object

RETURN $Order;

Returning a List

RETURN $FilteredOrders;

Returning an Enumeration

Declare the type in the signature with returns enum Module.Name; returns Enumeration(Module.Name) is the same. A bare returns Module.Name is also accepted when no entity has that name and an enumeration does. If an entity and an enumeration share the name, the entity wins, so write enum when a name could be either. Nanoflows and rules take the same spellings, and a rule must return a Boolean or an enumeration.

CREATE MICROFLOW Shop.SUB_Pick () RETURNS enum Shop.Variant AS $Variant
BEGIN
  RETURN Shop.Variant.Original;
END;

Returning Nothing

If the microflow returns nothing (void), you can omit the RETURN or use:

RETURN;

Annotations

Annotations are metadata decorators placed before an activity. They control visual layout and documentation in Mendix Studio Pro.

Position

Set the canvas position of the next activity:

@position(200, 100)
$Order = CREATE Sales.Order (Status = 'New');

Positions are optional. A microflow written without them is laid out by mxcli: the main line runs left to right and wraps onto a new row once it passes two canvas widths (2880 px); a guard — if … then …; return; end if — drops its branch into the lane below while the main line carries on above it; a custom error handler’s row hangs in that same lane, 130 px under its activity; and a case of four or more branches leaves the decision in three groups (top, right, bottom) so its lines do not cross. Whatever reaches down into the lane — a decision, a loop, an activity with an error handler of its own — waits for the lane to clear, so two consecutive handlers never share a row; a plain activity does not wait. A statement that carries @position is never moved, and becomes the start of the row for the statements after it; the statements before it keep the places the builder derives for them, and only a @position on the first statement moves the start event. So either place everything or nothing: a few hand-placed statements are not measured against what is laid out around them.

That is the default style. A project can choose another — the ELSE branch as a column under its decision, a guard’s early return kept on the line, no wrapping — in a committed layout-config.yaml; flows written without @position then follow it. See Choosing a layout style.

To re-arrange a flow that already exists — one drawn in Studio Pro, or one whose positions no longer fit after edits — run mxcli layout flows. It applies this same layout to the stored flow and changes nothing but coordinates; see Microflow and Nanoflow Layout.

Start event

The start event has no statement of its own, so @start goes on the first statement — the one the start flows into:

@start(145, 200)
@position(260, 200)
$Order = CREATE Sales.Order (Status = 'New');

It is optional. Omitted, the start is placed one spacing unit left of the first activity on that activity’s centre line, and a rewrite re-derives it so the start follows the activities when they move. A start that is not at that derived spot was placed on purpose — in Studio Pro or with @start — so it survives a rewrite that does not mention it, and DESCRIBE emits an @start line for it. An explicit @start overrides both.

Anchors

@anchor(from: X, to: Y) picks the side (top, right, bottom, left) each end of a flow attaches to: to is the flow arriving at the statement, from the flow leaving it. On an if, true: (from: …, to: …) and false: (…) are its branches, and from is the flow leaving its closing merge — the merge has no statement of its own, just as its position rides on the if as @merge:

create microflow Sales.ACT_CheckFactory ($Factory: Sales.Factory)
begin
  @anchor(from: bottom, to: top)
  @merge(2650, 200)
  if $Factory/Latitude = empty then
    log warning node 'Factory' 'No location';
  end if;
end;

This is how DESCRIBE writes a decision whose merge drops onto the next row.

An annotation never reaches the far end of its own outgoing flow: that end is the next statement’s to:. A straight vertical edge from A down to B is @anchor(from: bottom) on A and @anchor(to: top) on B.

A case names each outcome’s edge by its when value, (empty) included, and its own from: is the flow leaving its merge, as on an if:

create microflow Sales.ACT_RouteOrder ($Order: Sales.Order)
begin
  @anchor(Open: (from: right), (empty): (from: bottom, to: top))
  case $Order/Status
    when Open then
      log info node 'Order' 'open';
    when Closed then
      log info node 'Order' 'closed';
    when (empty) then
      log warning node 'Order' 'no status';
  end case;
end;

An outcome not named keeps its default side (with up to three outcomes: the first leaves the top corner, the second the right, the third the bottom). DESCRIBE writes an outcome only where its stored side differs from that.

Layout in DESCRIBE output

DESCRIBE MICROFLOW and DESCRIBE NANOFLOW print a layout annotation — @position, @merge, @anchor, @curve, @start — only where the layout would not produce it on its own. A flow written without annotations describes without them. A statement placed by hand keeps its @position, and the statements the layout places after it follow from it without one.

Whether an annotation is needed is not guessed from the coordinates. DESCRIBE builds the flow again from its own output, exactly as CREATE OR MODIFY would (nothing is written), and every node that lands elsewhere keeps its annotation. So re-executing a description never moves a node: a flow drawn in Studio Pro comes back with its layout, because Studio Pro positions are not ones the layout produces. DESCRIBE … WITH HANDLES and DESCRIBE … NORMALIZED still print every annotation.

Caption

Set a custom caption displayed on the activity in the canvas:

@caption 'Create new order'
$Order = CREATE Sales.Order (Status = 'New');

Color

Set the background color of the activity:

@color Green
COMMIT $Order;

Annotation (Visual Note)

Attach a visual annotation note to the next activity:

@annotation 'This step validates the input before saving'
VALIDATION FEEDBACK $Customer/Email MESSAGE 'Email is required';

A note is a node of its own, so one note can document several activities. Give it an id: at its first mention and refer to that id afterwards; without the id, two notes with the same text stay two notes:

@annotation(id: n1, text: 'Both of these touch the same record')
COMMIT $Order;
@annotation(id: n1)
COMMIT $Invoice;

The id is scoped to the statement being written and is not stored in the model: DESCRIBE numbers shared notes afresh every time.

Attaching a Note Already in the Flow

ALTER MICROFLOW (and ALTER NANOFLOW) inserts or replaces a fragment of a stored flow. The fragment cannot see the id: labels DESCRIBE prints, so it names a note that is already in the flow by its text, with existing::

ALTER MICROFLOW Sales.ACT_ProcessOrder {
  INSERT AFTER LOG NODE 'Sales' 'validated' BEGIN
    @annotation(existing: 'Validates the order')
    LOG INFO NODE 'Sales' 'discount applied';
  END;
};

The new activity is joined to the stored note, which keeps its identity, text and place on the canvas. The text must match exactly one note of the flow, as DESCRIBE prints it; when no note matches, or several do, the statement is refused and the message lists the flow’s notes. existing: never creates a note and takes no other parameter, and only an ALTER fragment accepts it — a CREATE states the whole flow and declares its notes.

Combining Annotations

Multiple annotations can be stacked before a single activity:

@position(300, 200)
@caption 'Validate and save'
@color Green
@annotation 'Final step: commit the validated order'
COMMIT $Order;

Complete Example

CREATE MICROFLOW Sales.ACT_ProcessOrder
FOLDER 'Orders/Processing'
BEGIN
  -- Parameters
  DECLARE $Order Sales.Order;
  DECLARE $ApplyDiscount Boolean;

  -- Local variable
  DECLARE $Total Decimal = 0;

  -- Retrieve order lines
  RETRIEVE $Lines FROM $Order/Sales.OrderLine_Order;

  -- Calculate total
  @caption 'Calculate total'
  $Total = CALL MICROFLOW Sales.SUB_CalculateTotal (
    OrderLines = $Lines
  );

  -- Apply discount if requested
  IF $ApplyDiscount THEN
    SET $Total = $Total * 0.9;
  END IF;

  -- Update order
  CHANGE $Order (
    TotalAmount = $Total,
    Status = 'Processed'
  );
  COMMIT $Order;

  RETURN $Order;
END;

Activity Types

This page documents every activity type available in MDL microflows. Activities are the individual steps that make up a microflow’s logic.

Object Operations

CREATE

Creates a new object of the specified entity type and assigns attribute values:

$Order = CREATE Sales.Order (
  OrderDate = [%CurrentDateTime%],
  Status = 'Draft',
  TotalAmount = 0
);

Attribute assignments are comma-separated Name = value pairs. The result is assigned to a variable.

CHANGE

Modifies attributes of an existing object:

CHANGE $Order (
  Status = 'Confirmed',
  TotalAmount = $CalculatedTotal
);

Multiple attributes can be changed in a single CHANGE statement.

COMMIT

Persists an object (or its changes) to the database:

COMMIT $Order;

Options:

  • WITHOUT EVENTS – skips the event handlers (before/after commit microflows), which otherwise run
  • REFRESH – refreshes the object in the client after committing
COMMIT $Order;
COMMIT $Order REFRESH;
COMMIT $Order REFRESH;

DELETE

Deletes an object from the database:

DELETE $Order;

ROLLBACK

Reverts uncommitted changes to an object, restoring it to its last committed state:

ROLLBACK $Order;
ROLLBACK $Order REFRESH;

The REFRESH option refreshes the object in the client.

Retrieval

RETRIEVE from Database (XPath)

Retrieves objects from the database using an optional XPath-style WHERE clause:

-- Retrieve a single object
RETRIEVE $Customer FROM Sales.Customer
  WHERE Email = $InputEmail
  FIRST;

-- Retrieve a list of objects
RETRIEVE $ActiveOrders FROM Sales.Order
  WHERE Status = 'Active';

-- Retrieve all objects of an entity
RETRIEVE $AllProducts FROM Sales.Product;

-- Retrieve with multiple conditions
RETRIEVE $RecentOrders FROM Sales.Order
  WHERE Status = 'Active'
  AND OrderDate > [%BeginOfCurrentDay%]
  LIMIT 50;

With FIRST the result is a single entity object (Mendix’s “First object” range). Otherwise the result is a list, including with LIMIT/OFFSET. A bare LIMIT 1 is a list of one (a script file without the header reads it differently; see Language Versions and Migration). describe prints the object range as FIRST.

A WHERE clause cannot reference a constant. WHERE Total > @Sales.MinimumTotal parses, and mxcli check refuses it (MDL-XPATH03): Mendix rejects a constant in any XPath constraint with CE0161, in a data source and an access rule as much as in a retrieve. Assign the constant to a variable and compare with the variable:

DECLARE $Minimum Decimal = @Sales.MinimumTotal;
RETRIEVE $LargeOrders FROM Sales.Order
  WHERE Total > $Minimum;

RETRIEVE by Association

Retrieves objects by following an association from an existing object:

-- Retrieve related objects via association
RETRIEVE $Lines FROM $Order/Sales.OrderLine_Order;

-- Retrieve a single associated object
RETRIEVE $Customer FROM $Order/Sales.Order_Customer;

What it binds follows the association: from the FROM entity of a Reference, the one object it points at; from its TO entity, the list of objects pointing at it (one object when the owner is Both); over a Reference set, a list from either end. A self-reference is followed from its FROM end, so a Reference from an entity to itself gives the one object and a Reference set the list. mxcli check -p --references reports a list used as an object (MDL-RETRIEVE03) and an object used as a list (MDL-RETRIEVE01).

The association path uses the format $Variable/Module.AssociationName. A bare name ($Order/Order_Customer) is resolved against the variable’s entity — through its generalizations, from either end of the association — when exactly one association of that name fits, and stored qualified; a name that fits none, or more than one, is refused by mxcli check -p --references and by exec.

RETRIEVE from Variable List

Retrieves from an in-memory list variable:

RETRIEVE $Match FROM $CustomerList
  WHERE Email = $SearchEmail
  LIMIT 1;

Call Activities

CALL MICROFLOW

Calls another microflow, passing parameters and optionally receiving a return value:

-- Call with return value
$Total = CALL MICROFLOW Sales.SUB_CalculateTotal (
  OrderLines = $Lines
);

-- Call without return value
CALL MICROFLOW Sales.SUB_SendNotification (
  Customer = $Customer,
  Message = 'Order confirmed'
);

-- Call with no parameters
$Config = CALL MICROFLOW Admin.GetSystemConfig ();

CALL NANOFLOW

Calls a nanoflow from within a microflow:

$Result = CALL NANOFLOW MyModule.ValidateInput (
  InputValue = $Value
);

CALL JAVA ACTION

Calls a Java action:

$Hash = CALL JAVA ACTION MyModule.HashPassword (
  Password = $RawPassword
);

Java action parameters follow the same Name = value syntax.

UI Activities

SHOW PAGE

Opens a page, passing parameters:

SHOW PAGE Sales.Order_Edit (Order = $Order);

The parameter syntax is PageParam = $MicroflowVar, with no $ on the page’s parameter name. Multiple parameters are comma-separated:

SHOW PAGE Sales.OrderDetail (
  Order = $Order,
  Customer = $Customer
);

The older spellings ($Order = $Order) and (Order: $Order) still parse but are deprecated (MDL-DEPR006, MDL-DEPR007); mxcli fmt --upgrade rewrites them.

The action’s own settings go in a with list after the arguments:

SHOW PAGE Sales.Order_Edit (Order = $Order) WITH title = 'Edit order';
SHOW PAGE Sales.Order_Overview WITH NumberOfPagesToClose = 1;  -- close the current page first

title overrides the page title. NumberOfPagesToClose is Studio Pro’s “Number of pages to close”: an Integer expression for how many pages to close before the page opens. Leaving it out closes none. It is a setting, not an argument — (NumberOfPagesToClose: 1) passes a page parameter of that name, which check --references refuses (Mendix build: CE1613).

CLOSE PAGE

Closes the current page, or the given number of pages:

CLOSE PAGE;      -- the current page
CLOSE PAGE 2;    -- the current page and the one that opened it

The count is a whole number of at least 1. Mendix stores it as NumberOfPagesToClose, and describe prints it when it is more than one.

Validation

VALIDATION FEEDBACK

Displays a validation error message on a specific attribute of an object:

VALIDATION FEEDBACK $Customer/Email MESSAGE 'Email address is required';
VALIDATION FEEDBACK $Order/TotalAmount MESSAGE 'Total must be greater than zero';

The syntax is VALIDATION FEEDBACK $Variable/AttributeName MESSAGE 'message text'.

Logging

LOG

Writes a message to the Mendix runtime log:

LOG INFO 'Order created successfully';
LOG WARNING 'Customer has no email address';
LOG ERROR 'Failed to process payment';

Log levels: INFO, WARNING, ERROR.

Optionally specify a log node name:

LOG INFO NODE 'OrderProcessing' 'Order created: ' + $Order/OrderNumber;
LOG ERROR NODE 'PaymentGateway' 'Payment failed for order ' + $Order/OrderNumber;

WITH STACKTRACE ticks Include latest stack trace, which is what an error handler usually wants. It comes after the template parameters, which also open with WITH:

LOG ERROR NODE 'PaymentGateway' 'Payment failed: {1}' WITH ({1} = $latestError/Message) WITH STACKTRACE;

Without it the box is unticked, which is Mendix’s default, so DESCRIBE prints WITH STACKTRACE only for a log that has it.

Email

SEND EMAIL

Sends mail over SMTP with the built-in Send Email activity (Studio Pro 11.13+, beta) — no Email Connector module needed. Microflows only.

SEND EMAIL (
  From: @Shop.MailSender,
  To: $Order/CustomerEmail,
  Cc: 'archive@example.com',
  Subject: 'Order {1} confirmed' WITH ({1} = $Order/OrderNumber),
  Body: TEMPLATE 'Hi {1}, your order ships soon.' WITH ({1} = $Order/CustomerName),
  HtmlBody: TEMPLATE '<p>Your order ships soon.</p>',
  Headers: ('X-Correlation-Id': 'order-confirmation'),
  Attachment: $Invoice,
  Host: @Shop.SmtpHost,
  Port: @Shop.SmtpPort,
  SecurityType: SSL,
  CheckServerIdentity: true,
  ConnectionTimeout: 30000,
  Authentication: BASIC (Username: @Shop.SmtpUser, Password: @Shop.SmtpPassword),
) ON ERROR WITHOUT ROLLBACK BEGIN
  LOG ERROR NODE 'Mail' 'Sending failed';
END ERROR;

The activity’s settings are one property list, as for CALL REST SERVICE (Activity Settings); the order of the keys does not matter. From, Host, Port and at least one of To, Cc and Bcc are required — mxbuild reports a missing one as CE0166.

  • Addresses, host, port, user and password are expressions. All are String except Port, which is Integer or Long. Prefer constants for the connection settings, and never a literal password.
  • Subject is a text template, as in LOG; Body and HtmlBody take template '…', as a REST body does. Placeholders {1}, {2} … are filled by String expressions in WITH. Any other expression is stored as the template '{1}'.
  • Headers are plain strings: the name holds letters, digits and hyphens.
  • Attachment is a System.FileDocument variable, a list of them, or a specialization.
  • Defaults, which DESCRIBE leaves out: SecurityType: tls, ConnectionTimeout: 20000 (milliseconds), no CheckServerIdentity (it applies to SSL only), no authentication.

Studio Pro’s Test Email tab is an editor tool and is not part of MDL; rewriting the microflow stores it empty.

Database Query Execution

EXECUTE DATABASE QUERY

Executes a Database Connector query defined in the project:

-- Basic execution (3-part qualified name: Module.Connection.Query)
$Result = EXECUTE DATABASE QUERY MyModule.MyConn.GetCustomers;

-- Dynamic SQL query
$Result = EXECUTE DATABASE QUERY MyModule.MyConn.SearchQuery
  DYNAMIC 'SELECT * FROM customers WHERE name LIKE ?';

-- With parameters
$Result = EXECUTE DATABASE QUERY MyModule.MyConn.GetByEmail (EmailParam = $Email);

-- With runtime connection override
$Result = EXECUTE DATABASE QUERY MyModule.MyConn.GetData
  CONNECTION $RuntimeConnString;

The query name follows a three-part naming convention: Module.ConnectionName.QueryName.

Note: Error handling (ON ERROR) is not supported on EXECUTE DATABASE QUERY activities.

Summary Table

ActivitySyntaxReturns
Create object$Var = CREATE Module.Entity (Attr = val) [COMMIT [WITHOUT EVENTS]] [REFRESH];Entity object
Change objectCHANGE $Var (Attr = val) [COMMIT [WITHOUT EVENTS]] [REFRESH];–
CommitCOMMIT $Var [WITHOUT EVENTS] [REFRESH];Omitted = with events
DeleteDELETE $Var [REFRESH];–
RollbackROLLBACK $Var [REFRESH];–
Retrieve (DB)RETRIEVE $Var FROM Module.Entity [WHERE ...] [LIMIT n];Entity or list
Retrieve (assoc)RETRIEVE $Var FROM $Obj/Module.Assoc;Entity or list
Call microflow$Var = CALL MICROFLOW Module.Name (Param = $val);Any type
Call nanoflow$Var = CALL NANOFLOW Module.Name (Param = $val);Any type
Call Java action$Var = CALL JAVA ACTION Module.Name (Param = val);Any type
Show pageSHOW PAGE Module.Page (Param = $val) [WITH title = '…', NumberOfPagesToClose = n];–
Close pageCLOSE PAGE [n];–
ValidationVALIDATION FEEDBACK $Var/Attr MESSAGE 'msg';–
LogLOG INFO|WARNING|ERROR [NODE 'name'] 'msg';–
DB query$Var = EXECUTE DATABASE QUERY Module.Conn.Query;Result set
Send emailSEND EMAIL (From: …, To: …, Subject: '…', Host: …, Port: …); (11.13+)–
AssignmentSET $Var = expression;–
Change listADD $Item TO $List; / REMOVE $Item FROM $List; / CLEAR $List; / SET $List = $Other;–

Control Flow

MDL microflows support conditional branching, loops, and error handling to control the execution path of your logic.

IF / ELSE

Conditional branching executes different activities based on a boolean expression.

Basic IF

IF $Order/TotalAmount > 1000 THEN
  CHANGE $Order (DiscountApplied = true);
END IF;

IF / ELSE

IF $Customer/Email != empty THEN
  CALL MICROFLOW Sales.SUB_SendEmail (Customer = $Customer);
ELSE
  LOG WARNING 'Customer has no email address';
END IF;

ELSIF

For multi-way branching on anything other than an enumeration, chain the conditions with ELSIF. (To branch on an enumeration, use CASE (Enum Split) instead — it maps to a Mendix enum split rather than a chain of decisions.)

IF $Order/TotalAmount > 10000 THEN
  CHANGE $Order (DiscountPercentage = 15);
ELSIF $Order/TotalAmount > 5000 THEN
  CHANGE $Order (DiscountPercentage = 10);
ELSIF $Order/TotalAmount > 1000 THEN
  CHANGE $Order (DiscountPercentage = 5);
ELSE
  CHANGE $Order (DiscountPercentage = 0);
END IF;

Mendix has no elsif of its own: each ELSIF arm is a decision in the previous arm’s false branch, exactly as if you had written a nested IF there. DESCRIBE prints an else branch that holds nothing but one IF as an ELSIF arm.

Because each arm is a decision on the canvas, it takes the layout annotations a decision takes (@position, @caption, @merge, @anchor, @curve), written before the ELSIF keyword:

@caption 'Big order?'
IF $Order/TotalAmount > 10000 THEN
  CHANGE $Order (DiscountPercentage = 15);
@caption 'Medium order?'
ELSIF $Order/TotalAmount > 5000 THEN
  CHANGE $Order (DiscountPercentage = 10);
END IF;

Comparing Enumerations

An enumeration is compared against its qualified value, never a string literal. A string literal here fails the build with CE0117 “Error(s) in expression” — mxcli check does not catch it (see expression type checking), so the first sign is a failed build.

-- CORRECT
IF $Order/Status = Sales.OrderStatus.Draft THEN ... END IF;

-- WRONG: CE0117 "Error(s) in expression."
IF $Order/Status = 'Draft' THEN ... END IF;

The same applies to putting an enumeration into a string — concatenating it directly is CE0117, so render it first:

-- CORRECT
LOG WARNING 'Unexpected status: ' + getCaption($Order/Status);

-- WRONG: CE0117
LOG WARNING 'Unexpected status: ' + $Order/Status;

The string form is accepted outside comparisons — in a CREATE/CHANGE member value, an attribute DEFAULT, and an XPath constraint (where enums live as strings at the database level). The qualified form works everywhere, so prefer it.

Complex Conditions

Conditions support AND, OR, and parentheses:

IF $Amount > 0 AND $Customer != empty THEN
  COMMIT $Order;
END IF;

IF ($Status = 'Active' OR $Status = 'Pending') AND $IsValid = true THEN
  CALL MICROFLOW Sales.ProcessOrder (Order = $Order);
END IF;

LOOP (FOR EACH)

Iterates over each item in a list:

LOOP $Line IN $OrderLines
BEGIN
  CHANGE $Line (
    LineTotal = $Line/Quantity * $Line/UnitPrice
  );
  COMMIT $Line;
END LOOP;

The loop variable ($Line) is automatically declared and takes the entity type of the list.

LOOP with Nested Logic

LOOP $Order IN $PendingOrders
BEGIN
  IF $Order/TotalAmount > 0 THEN
    CHANGE $Order (Status = 'Confirmed');
    COMMIT $Order;
  ELSE
    DELETE $Order;
  END IF;
END LOOP;

BREAK and CONTINUE

Use BREAK to exit a loop early, and CONTINUE to skip to the next iteration:

LOOP $Item IN $Items
BEGIN
  IF $Item/IsInvalid = true THEN
    CONTINUE;
  END IF;

  IF $Item/Type = 'StopSignal' THEN
    BREAK;
  END IF;

  CALL MICROFLOW Sales.ProcessItem (Item = $Item);
END LOOP;

WHILE Loop

Executes a block repeatedly as long as a condition remains true:

DECLARE $Counter Integer = 0;

WHILE $Counter < 10
BEGIN
  SET $Counter = $Counter + 1;
  LOG INFO 'Iteration: ' + toString($Counter);
END WHILE;

Caution: Ensure the condition will eventually become false to avoid infinite loops.

BEGIN and END WHILE are required, as they are for LOOP.

Error Handling

ON ERROR Suffix

Error handling is applied as a suffix to individual activities. There is no TRY...CATCH block in MDL. Instead, you specify error handling behavior on specific activities.

ON ERROR CONTINUE

Ignores the error and continues to the next activity:

COMMIT $Order ON ERROR CONTINUE;

ON ERROR ROLLBACK

Rolls back the current transaction and continues:

DELETE $Order ON ERROR ROLLBACK;

ON ERROR with Handler Block

Executes a custom error handling block when the activity fails:

COMMIT $Order ON ERROR BEGIN
  LOG ERROR 'Failed to commit order: ' + $Order/OrderNumber;
  ROLLBACK $Order;
END ERROR;

The handler block can contain any activities – logging, rollback, showing validation messages, etc.

The handler is flow, so it is written BEGIN … END ERROR like every other flow block; add WITHOUT ROLLBACK before BEGIN to keep the database changes made so far. The older brace form, ON ERROR { … }, still parses and builds the same handler, with warning MDL-DEPR540; mxcli fmt --upgrade rewrites it.

A handler that does not end in RETURN or RAISE ERROR falls through: after it runs, the microflow continues with the statement after the activity, through a merge placed where the two paths meet. Inside an IF, CASE or loop body that is the next statement of the same branch; for a branch’s last activity it is what follows the END IF / END CASE. Statements that read the activity’s output are skipped on the error path, since a failed activity produced none. DESCRIBE writes the merge’s position as @merge(x, y) on the activity, the same annotation a decision uses for the merge that closes it. A handler that rejoins somewhere else names that point with JOIN <label> and MERGE <label>.

Error Handling Examples

-- Continue despite retrieval failure
RETRIEVE $Config FROM Admin.SystemConfig FIRST ON ERROR CONTINUE;

-- Custom error handler for external call
$Response = CALL MICROFLOW Integration.CallExternalAPI (
  Payload = $RequestBody
) ON ERROR BEGIN
  LOG ERROR NODE 'Integration' 'External API call failed';
  SET $Response = empty;
END ERROR;

-- Rollback on commit failure
COMMIT $Order ON ERROR ROLLBACK;

Note: ON ERROR is not supported on EXECUTE DATABASE QUERY activities.

RAISE ERROR (handler-only)

RAISE ERROR builds Mendix’s error event, which re-raises the error currently being handled. Mendix allows one only where an error is in scope, so it belongs inside an ON ERROR BEGIN ... END ERROR block:

CALL MICROFLOW Integration.CallExternalAPI (Payload = $Body) ON ERROR BEGIN
  LOG ERROR NODE 'Integration' 'API call failed, re-raising';
  RAISE ERROR;
END ERROR;

On the main flow it is refused as MDL084, at any nesting depth — inside an IF, inside a LOOP, or as the whole body. Studio Pro will not draw that shape, and mxbuild rejects it with CE0710 “The main flow cannot join an error flow or end in an error event.” The same applies inside a rule, which the same flow builder produces.

Mendix has no main-flow “throw” activity. To fail deliberately from the normal path, call a Java action that throws:

CREATE JAVA ACTION Module.JA_RaiseTechnicalError (Message: String NOT NULL) RETURNS Boolean AS
$$ throw new com.mendix.systemwideinterfaces.MendixRuntimeException(Message); $$;

CASE (Enum Split)

CASE branches on an enumeration and compiles to a Mendix enum split. It is not a general-purpose switch: the source is an enum attribute or variable, and the values are bare enum member names.

CASE $Order/Status
  WHEN Draft, Submitted THEN
    LOG INFO 'Not shipped yet';
  WHEN Approved THEN
    LOG INFO 'Ready to ship';
  WHEN Shipped, (empty) THEN
    LOG INFO 'Nothing to do';
END CASE;

Rules, each of which mxcli check enforces:

RuleWhy
Values are bare identifiers — not 'Quoted', not Module.Enum.ValueParse error otherwise
One branch per enum value, including (empty)A missing (empty) is MDL056; mxbuild reports CE0079 for any uncovered value. Required even when the attribute is not null
No ELSEMDL008. An enum split is exclusive with one outgoing flow per value; mxbuild reports CE0079 per uncovered value and CE0773 on the else flow
No AS aliasParse error: mismatched input 'as' expecting WHEN

Several values may share a branch (WHEN Draft, Submitted THEN …). CASE works in nanoflows on the same terms.

SPLIT TYPE (Object Type Split)

SPLIT TYPE branches on an object’s runtime specialization and compiles to a Mendix object type decision. Branches use the same WHEN … THEN shape as CASE, so the two splits differ only in what they branch on.

SPLIT TYPE $Animal
  WHEN Zoo.Dog THEN
    CAST $Dog;
    LOG INFO 'woof';
  WHEN Zoo.Cat THEN
    LOG INFO 'meow';
  WHEN Zoo.Animal THEN
    LOG INFO 'some other animal';
  WHEN (empty) THEN
    LOG INFO 'no animal at all';
END SPLIT;

Use CAST inside a branch to bind the specialized variable its body needs.

RuleWhy
A branch for every subtype and the base entitymxbuild reports CE0090 “The ‘X’ value should be configured for an outgoing flow” for each type with no branch. The base entity is what covers “none of the more specific ones”
WHEN (empty) THEN is the null-object branch, not a defaultIt is taken when the variable is empty. It does not cover unnamed types — measured on 11.13.0, one named type plus an empty branch still gives CE0090 for every other type. Branch on the base entity for that
The empty branch cannot be omittedCE0089. mxcli emits the flow unconditionally, so MDL cannot express a split without one
A non-void microflow needs a RETURN after END SPLIT;Branches converge on a merge that continues to the end event — otherwise MDL003 and CE0067

The pre-#913 spelling — CASE Zoo.Dog for a branch and ELSE for the empty one — still parses and builds the identical flow, but warns MDL065. It was replaced because CASE introduced a branch here while introducing the subject in CASE $x WHEN v THEN, and because ELSE reads as a default branch and never was one.

SPLIT TYPE works in nanoflows on the same terms.

Unsupported Control Flow

The following constructs are not supported in MDL and will cause parse errors:

UnsupportedUse Instead
CASE ... WHEN 'String' ... ELSE ...Bare enum values and a branch per value — see CASE (Enum Split); CASE itself is supported
TRY ... CATCH ... END TRYON ERROR BEGIN ... END ERROR blocks on individual activities

Complete Example

CREATE MICROFLOW Sales.ACT_ProcessBatch
FOLDER 'Batch'
BEGIN
  DECLARE $Orders List of Sales.Order = empty;
  DECLARE $SuccessCount Integer = 0;
  DECLARE $ErrorCount Integer = 0;

  RETRIEVE $Orders FROM Sales.Order
    WHERE Status = 'Pending';

  LOOP $Order IN $Orders
  BEGIN
    IF $Order/TotalAmount <= 0 THEN
      LOG WARNING 'Skipping order with zero amount: ' + $Order/OrderNumber;
      CONTINUE;
    END IF;

    @caption 'Process order'
    CALL MICROFLOW Sales.SUB_ProcessSingleOrder (
      Order = $Order
    ) ON ERROR BEGIN
      LOG ERROR 'Failed to process order: ' + $Order/OrderNumber;
      SET $ErrorCount = $ErrorCount + 1;
      CONTINUE;
    END ERROR;

    SET $SuccessCount = $SuccessCount + 1;
  END LOOP;

  LOG INFO 'Batch complete: ' + toString($SuccessCount) + ' processed, '
    + toString($ErrorCount) + ' errors';
END;

Expressions

Expressions in MDL are used in conditions, attribute assignments, variable assignments, and log messages within microflows. They follow Mendix expression syntax.

Literals

String Literals

Strings are enclosed in single quotes:

'Hello, world'
'Order #1234'

To include a single quote within a string, double it:

'it''s here'
'Customer''s address'

Important: Do not use backslash escaping (\'). Mendix expression syntax requires doubled single quotes.

Numeric Literals

42          -- Integer
3.14        -- Decimal
-100        -- Negative integer
1.5e10      -- Scientific notation

Boolean Literals

true
false

Empty

The empty literal represents a null/undefined value:

DECLARE $List List of Sales.Order = empty;

IF $Customer = empty THEN
  LOG WARNING 'No customer found';
END IF;

Operators

Arithmetic Operators

OperatorDescriptionExample
+Addition / string concatenation$Price + $Tax
-Subtraction$Total - $Discount
*Multiplication$Quantity * $UnitPrice
divDivision$Total div $Count
modModulo (remainder)$Index mod 2

String concatenation uses +:

SET $FullName = $FirstName + ' ' + $LastName;
LOG INFO 'Processing order: ' + $Order/OrderNumber;

Comparison Operators

OperatorDescriptionExample
=Equal$Status = 'Active'
!=Not equal$Status != 'Closed'
>Greater than$Amount > 1000
<Less than$Count < 10
>=Greater than or equal$Age >= 18
<=Less than or equal$Score <= 100

Logical Operators

OperatorDescriptionExample
ANDLogical AND$IsActive AND $HasEmail
ORLogical OR$IsAdmin OR $IsManager
NOTLogical NOTNOT $IsDeleted

Parentheses control precedence:

IF ($Status = 'Active' OR $Status = 'Pending') AND $Amount > 0 THEN
  -- ...
END IF;

Attribute Access

Access attributes of an object variable with /:

$Order/TotalAmount
$Customer/Email
$Line/Quantity

This is used in conditions, assignments, and expressions:

IF $Order/TotalAmount > 1000 THEN
  SET $Discount = $Order/TotalAmount * 0.1;
END IF;

Date/Time Tokens

Mendix provides built-in date/time tokens enclosed in [% ... %]:

TokenDescription
[%CurrentDateTime%]Current date and time
[%BeginOfCurrentDay%]Start of today (00:00)
[%EndOfCurrentDay%]End of today (23:59:59)
[%BeginOfCurrentWeek%]Start of the current week
[%BeginOfCurrentMonth%]Start of the current month
[%BeginOfCurrentYear%]Start of the current year

Usage in expressions:

$Order = CREATE Sales.Order (
  OrderDate = [%CurrentDateTime%],
  DueDate = [%EndOfCurrentDay%]
);

RETRIEVE $TodayOrders FROM Sales.Order
  WHERE OrderDate > [%BeginOfCurrentDay%];

String Templates

String concatenation with + is the primary way to build dynamic strings:

SET $Message = 'Order ' + $Order/OrderNumber + ' has been ' + $Order/Status;
LOG INFO NODE 'Orders' 'Total: ' + toString($Total) + ' for customer ' + $Customer/Name;

Type Conversion

Use built-in functions for type conversion in expressions:

toString($IntValue)           -- Integer/Decimal/Boolean to String

Enumeration Values

Reference enumeration values with their qualified name:

$Order = CREATE Sales.Order (
  Status = Sales.OrderStatus.Draft
);

IF $Order/Status = Sales.OrderStatus.Confirmed THEN
  -- process confirmed order
END IF;

A plain string is accepted where the slot is already typed as the enumeration — a CREATE/CHANGE member value, an attribute DEFAULT, and an XPath constraint (enums are stored as strings at the database level):

$Order = CREATE Sales.Order (
  Status = 'Draft'
);

It is not accepted in an expression. Comparing an enumeration to a string, or concatenating one into a string, fails the build with CE0117 “Error(s) in expression” — and mxcli check does not catch it, so the build is where you find out:

IF $Order/Status = 'Draft' THEN ...              -- CE0117
LOG WARNING 'Status: ' + $Order/Status;          -- CE0117
IF $Order/Status = Sales.OrderStatus.Draft THEN ...        -- correct
LOG WARNING 'Status: ' + getCaption($Order/Status);        -- correct

The qualified form works in every context, so prefer it throughout.

Expression Contexts

Expressions appear in several places within microflow activities:

In Conditions (IF, WHILE, WHERE)

IF $Order/TotalAmount > 0 AND $Order/Status != Sales.OrderStatus.Cancelled THEN ...
WHILE $Counter < $MaxRetries BEGIN ... END WHILE;
RETRIEVE $Active FROM Sales.Order WHERE Status = 'Active';   -- XPath: string is correct here

In Attribute Assignments (CREATE, CHANGE)

$Order = CREATE Sales.Order (
  OrderDate = [%CurrentDateTime%],
  Description = 'Order for ' + $Customer/Name,
  TotalAmount = $Subtotal + $Tax
);

In SET Assignments

SET $Counter = $Counter + 1;
SET $IsEligible = $Customer/Age >= 18 AND $Customer/IsActive;
SET $FullName = $Customer/FirstName + ' ' + $Customer/LastName;

In LOG Messages

LOG INFO 'Processed ' + toString($Count) + ' orders';
LOG ERROR NODE 'Validation' 'Invalid amount: ' + toString($Order/TotalAmount);

In VALIDATION FEEDBACK Messages

VALIDATION FEEDBACK $Customer/Email MESSAGE 'Please provide a valid email address';

Nanoflows vs Microflows

Nanoflows are client-side logic flows that execute in the user’s browser or on mobile devices. They share the same MDL syntax as microflows but have a different set of capabilities and restrictions.

Key Differences

AspectMicroflowNanoflow
ExecutionServer-side (Mendix runtime)Client-side (browser/mobile)
Database accessFull (retrieve, commit, delete)No direct database access
TransactionsSupported (with rollback)Not supported
Java actionsSupportedNot supported
JavaScript actionsNot supportedSupported
Show pageSupportedSupported
Close pageSupportedSupported
NetworkRequires server round-tripNo network call (fast)
OfflineNot available offlineAvailable offline
Error handlingON ERROR blocksPer-action ON ERROR (no ErrorEvent)

When to Use Which

Use a microflow when you need to:

  • Retrieve data from or commit data to the database
  • Call external web services or REST APIs
  • Execute Java actions
  • Perform batch operations on large data sets
  • Use transactions with rollback support

Use a nanoflow when you need to:

  • Respond quickly to user actions without server delay
  • Perform client-side validation
  • Toggle UI state (show/hide elements)
  • Navigate between pages
  • Work offline on mobile devices

CREATE NANOFLOW Syntax

CREATE [OR MODIFY] NANOFLOW <Module.Name>
  [FOLDER '<path>']
BEGIN
  [<declarations>]
  [<activities>]
  [RETURN <value>;]
END;

The syntax is identical to CREATE MICROFLOW except for the keyword.

Supported Activities in Nanoflows

Object Operations

-- Create an object (in memory only)
$Item = CREATE Sales.CartItem (
  Quantity = 1,
  ProductName = $Product/Name
);

-- Change an object in memory
CHANGE $Item (Quantity = $Item/Quantity + 1);

Calling Other Flows

-- Call another nanoflow
$Result = CALL NANOFLOW Sales.NAV_ValidateCart (Cart = $Cart);

-- Call a microflow (triggers server round-trip)
$ServerResult = CALL MICROFLOW Sales.ACT_SubmitOrder (Order = $Order);

-- Call a JavaScript action
$HasNetwork = CALL JAVASCRIPT ACTION NanoflowCommons.HasConnectivity();

UI Activities

-- Show a page
SHOW PAGE Sales.CartDetail (Cart = $Cart);

-- Close the current page
CLOSE PAGE;

Validation

VALIDATION FEEDBACK $Item/Quantity MESSAGE 'Quantity must be at least 1';

Logging

LOG INFO 'Cart updated with ' + toString($ItemCount) + ' items';

Control Flow

IF $Cart/ItemCount = 0 THEN
  VALIDATION FEEDBACK $Cart/ItemCount MESSAGE 'Cart is empty';
  RETURN false;
ELSE
  SHOW PAGE Sales.Checkout (Cart = $Cart);
  RETURN true;
END IF;

Activities NOT Available in Nanoflows

The following activities are server-only and cannot be used in nanoflows:

  • CALL JAVA ACTION — Java actions cannot run client-side
  • ErrorEvent / RAISE ERROR — error events are not available in nanoflows
  • DOWNLOAD FILE — file downloads require server-side processing
  • CALL REST SERVICE / SEND REST REQUEST — REST calls are server-side
  • IMPORT FROM MAPPING / EXPORT TO MAPPING — mapping operations are server-side
  • EXECUTE DATABASE QUERY — direct SQL requires server
  • TRANSFORM JSON — JSON transformations are server-side
  • SHOW HOME PAGE — home page navigation is server-side
  • CALL EXTERNAL ACTION — external actions are server-side
  • All workflow actions (call/open workflow, set task outcome, user task, etc.)

Note: Per-action error handling is supported in nanoflows, per activity. Only ErrorEvent (raise error as a standalone flow action) is forbidden. A nanoflow has no database transaction, so an activity without a clause aborts the flow on error. declare, set, retrieve and delete take every clause; create, commit, call nanoflow and call microflow take only on error without rollback begin … end error; change, log, show page, close page, show message and validation feedback take none. Every other form is CE6035 at build time, and mxcli refuses it (MDL091) — measured on Mendix 11.14.0.

SHOW and DESCRIBE

LIST NANOFLOWS;
LIST NANOFLOWS IN MyModule;
DESCRIBE NANOFLOW MyModule.NAV_ShowDetails;

DROP

DROP NANOFLOW MyModule.NAV_ShowDetails;

Folder Organization

CREATE NANOFLOW Sales.NAV_OpenCart
FOLDER 'Navigation'
BEGIN
  SHOW PAGE Sales.Cart_Overview ();
END;
MOVE NANOFLOW Sales.NAV_OpenCart TO FOLDER 'UI/Navigation';

Example: Client-Side Validation

CREATE NANOFLOW Sales.NAV_ValidateOrder
FOLDER 'Validation'
BEGIN
  DECLARE $Order Sales.Order;
  DECLARE $IsValid Boolean = true;

  IF $Order/CustomerName = empty THEN
    VALIDATION FEEDBACK $Order/CustomerName MESSAGE 'Customer name is required';
    SET $IsValid = false;
  END IF;

  IF $Order/TotalAmount <= 0 THEN
    VALIDATION FEEDBACK $Order/TotalAmount MESSAGE 'Total must be greater than zero';
    SET $IsValid = false;
  END IF;

  RETURN $IsValid;
END;

Example: Page Navigation

CREATE NANOFLOW Sales.NAV_GoToOrderDetail
BEGIN
  DECLARE $Order Sales.Order;

  SHOW PAGE Sales.Order_Detail (Order = $Order);
END;

Security

Nanoflow access control uses GRANT/REVOKE to specify which module roles can execute a nanoflow. See Grant & Revoke and Document Access for full syntax and examples.

Common Patterns

This page shows frequently used microflow patterns in MDL, including CRUD operations, validation, batch processing, and important anti-patterns to avoid.

CRUD Patterns

Create and Commit

CREATE MICROFLOW Sales.ACT_CreateCustomer
FOLDER 'Customer'
BEGIN
  DECLARE $Name String;
  DECLARE $Email String;
  DECLARE $Customer Sales.Customer;

  $Customer = CREATE Sales.Customer (
    Name = $Name,
    Email = $Email,
    CreatedDate = [%CurrentDateTime%],
    IsActive = true
  );
  COMMIT $Customer;

  SHOW PAGE Sales.Customer_Edit (Customer = $Customer);
  RETURN $Customer;
END;

Retrieve and Update

CREATE MICROFLOW Sales.ACT_DeactivateCustomer
FOLDER 'Customer'
BEGIN
  DECLARE $Customer Sales.Customer;

  CHANGE $Customer (
    IsActive = false,
    DeactivatedDate = [%CurrentDateTime%]
  );
  COMMIT $Customer;
END;

Retrieve with Filtering

CREATE MICROFLOW Sales.ACT_GetActiveOrders
FOLDER 'Orders'
BEGIN
  RETRIEVE $Orders FROM Sales.Order
    WHERE Status = 'Active'
    AND OrderDate > [%BeginOfCurrentMonth%];

  RETURN $Orders;
END;

Delete with Confirmation

CREATE MICROFLOW Sales.ACT_DeleteOrder
FOLDER 'Orders'
BEGIN
  DECLARE $Order Sales.Order;

  -- Delete related order lines first
  RETRIEVE $Lines FROM $Order/Sales.OrderLine_Order;
  LOOP $Line IN $Lines
  BEGIN
    DELETE $Line;
  END LOOP;

  DELETE $Order;
END;

Validation Pattern

Validate an object before saving, showing feedback on invalid fields:

CREATE MICROFLOW Sales.ACT_SaveCustomer
FOLDER 'Customer'
BEGIN
  DECLARE $Customer Sales.Customer;
  DECLARE $IsValid Boolean = true;

  -- Validate required fields
  IF $Customer/Name = empty THEN
    VALIDATION FEEDBACK $Customer/Name MESSAGE 'Name is required';
    SET $IsValid = false;
  END IF;

  IF $Customer/Email = empty THEN
    VALIDATION FEEDBACK $Customer/Email MESSAGE 'Email is required';
    SET $IsValid = false;
  END IF;

  -- Check for duplicate email
  IF $IsValid THEN
    RETRIEVE $Existing FROM Sales.Customer
      WHERE Email = $Customer/Email
      FIRST;
    IF $Existing != empty THEN
      VALIDATION FEEDBACK $Customer/Email MESSAGE 'A customer with this email already exists';
      SET $IsValid = false;
    END IF;
  END IF;

  -- Save only if valid
  IF $IsValid THEN
    COMMIT $Customer;
    CLOSE PAGE;
  END IF;
END;

Batch Processing Pattern

Process a list of items one at a time with error tracking:

CREATE MICROFLOW Sales.ACT_ProcessPendingOrders
FOLDER 'Batch'
BEGIN
  DECLARE $SuccessCount Integer = 0;
  DECLARE $ErrorCount Integer = 0;

  RETRIEVE $PendingOrders FROM Sales.Order
    WHERE Status = 'Pending';

  LOOP $Order IN $PendingOrders
  BEGIN
    CALL MICROFLOW Sales.SUB_ProcessOrder (
      Order = $Order
    ) ON ERROR BEGIN
      LOG ERROR NODE 'BatchProcess' 'Failed to process order: ' + $Order/OrderNumber;
      SET $ErrorCount = $ErrorCount + 1;
      CONTINUE;
    END ERROR;

    SET $SuccessCount = $SuccessCount + 1;
  END LOOP;

  LOG INFO NODE 'BatchProcess' 'Batch complete: '
    + toString($SuccessCount) + ' succeeded, '
    + toString($ErrorCount) + ' failed';
END;

Sub-Microflow Pattern

Break complex logic into reusable sub-microflows:

-- Main microflow delegates to sub-microflow
CREATE MICROFLOW Sales.ACT_PlaceOrder
FOLDER 'Orders'
BEGIN
  DECLARE $Order Sales.Order;

  -- Validate
  $IsValid = CALL MICROFLOW Sales.SUB_ValidateOrder (Order = $Order);
  IF NOT $IsValid THEN
    RETURN false;
  END IF;

  -- Calculate totals
  CALL MICROFLOW Sales.SUB_CalculateOrderTotals (Order = $Order);

  -- Finalize
  CHANGE $Order (Status = 'Placed');
  COMMIT $Order;

  RETURN true;
END;
-- Reusable sub-microflow
CREATE MICROFLOW Sales.SUB_CalculateOrderTotals
FOLDER 'Orders'
BEGIN
  DECLARE $Order Sales.Order;
  DECLARE $Total Decimal = 0;

  RETRIEVE $Lines FROM $Order/Sales.OrderLine_Order;
  LOOP $Line IN $Lines
  BEGIN
    SET $Total = $Total + ($Line/Quantity * $Line/UnitPrice);
  END LOOP;

  CHANGE $Order (TotalAmount = $Total);
END;

Association Retrieval Pattern

Retrieve related objects through associations:

CREATE MICROFLOW Sales.ACT_GetOrderSummary
FOLDER 'Orders'
BEGIN
  DECLARE $Order Sales.Order;

  -- Retrieve related customer
  RETRIEVE $Customer FROM $Order/Sales.Order_Customer;

  -- Retrieve order lines
  RETRIEVE $Lines FROM $Order/Sales.OrderLine_Order;

  -- Use the retrieved data
  LOG INFO 'Order ' + $Order/OrderNumber
    + ' for customer ' + $Customer/Name
    + ' has ' + toString(length($Lines)) + ' lines';

  RETURN $Order;
END;

When you have a list and need to find a matching item, retrieve from the list variable:

CREATE MICROFLOW Import.ACT_MatchDepartments
FOLDER 'Import'
BEGIN
  DECLARE $Employees List of Import.Employee = empty;
  DECLARE $Departments List of Import.Department = empty;

  -- Retrieve all departments for lookup
  RETRIEVE $Departments FROM Import.Department;

  LOOP $Employee IN $Employees
  BEGIN
    -- O(N) lookup: retrieve from in-memory list
    RETRIEVE $Dept FROM $Departments
      WHERE Name = $Employee/DepartmentName
      LIMIT 1;

    IF $Dept != empty THEN
      CHANGE $Employee (Employee_Department = $Dept);
      COMMIT $Employee;
    END IF;
  END LOOP;
END;

Error Handling Pattern

Wrap risky operations with error handlers:

CREATE MICROFLOW Integration.ACT_SyncData
FOLDER 'Integration'
BEGIN
  DECLARE $Config Integration.SyncConfig;
  DECLARE $Success Boolean = false;

  RETRIEVE $Config FROM Integration.SyncConfig FIRST;

  @caption 'Call external API'
  $Response = CALL MICROFLOW Integration.SUB_CallExternalAPI (
    Config = $Config
  ) ON ERROR BEGIN
    LOG ERROR NODE 'Integration' 'External API call failed';
    RETURN false;
  END ERROR;

  IF $Response != empty THEN
    @caption 'Process response'
    CALL MICROFLOW Integration.SUB_ProcessResponse (
      Response = $Response
    ) ON ERROR BEGIN
      LOG ERROR NODE 'Integration' 'Failed to process response';
      RETURN false;
    END ERROR;
    SET $Success = true;
  END IF;

  RETURN $Success;
END;

Anti-Patterns (Avoid These)

The following patterns are common mistakes that cause bugs, performance problems, or parse errors. The mxcli check command detects these automatically.

Never Create Empty List Variables as Loop Sources

Creating an empty list and then immediately looping over it is always wrong. If you need to process a list, accept it as a microflow parameter or retrieve it from the database.

-- WRONG: empty list means the loop never executes
DECLARE $Items List of Sales.Item = empty;
LOOP $Item IN $Items
BEGIN
  -- this code never runs!
END LOOP;
-- CORRECT: accept the list as a parameter
CREATE MICROFLOW Sales.ACT_ProcessItems
BEGIN
  DECLARE $Items List of Sales.Item = empty;  -- parameter, filled by caller

  RETRIEVE $Items FROM Sales.Item WHERE Status = 'Pending';  -- or retrieve from DB

  LOOP $Item IN $Items
  BEGIN
    -- process each item
  END LOOP;
END;

Never Use Nested Loops for List Matching

Nested loops are O(N^2) and cause severe performance problems with large data sets. Instead, loop over the primary list and use RETRIEVE ... FROM $List WHERE ... LIMIT 1 to find matches.

-- WRONG: O(N^2) nested loop
LOOP $Employee IN $Employees
BEGIN
  LOOP $Department IN $Departments
  BEGIN
    IF $Department/Name = $Employee/DeptName THEN
      CHANGE $Employee (Employee_Department = $Department);
    END IF;
  END LOOP;
END LOOP;
-- CORRECT: O(N) lookup from list
LOOP $Employee IN $Employees
BEGIN
  RETRIEVE $Dept FROM $Departments
    WHERE Name = $Employee/DeptName
    LIMIT 1;

  IF $Dept != empty THEN
    CHANGE $Employee (Employee_Department = $Dept);
  END IF;
END LOOP;

Use Append Logic When Merging, Not Overwrite

When merging data from multiple sources, append new values rather than overwriting existing ones:

-- WRONG: overwrites existing notes
CHANGE $Customer (Notes = $NewNotes);
-- CORRECT: append with guard
IF $NewNotes != empty THEN
  CHANGE $Customer (Notes = $Customer/Notes + '
' + $NewNotes);
END IF;

Naming Conventions

Follow consistent naming patterns for microflows:

PrefixPurposeExample
ACT_User-triggered actionSales.ACT_CreateOrder
SUB_Sub-microflow (called by other microflows)Sales.SUB_CalculateTotal
DS_Data source for a page/widgetSales.DS_GetActiveOrders
VAL_Validation logicSales.VAL_ValidateOrder
SE_Scheduled event handlerSales.SE_ProcessBatch
BCO_Before commit eventSales.BCO_Order
ACO_After commit eventSales.ACO_Order

Validation Checklist

Before presenting a microflow to the user, verify these rules:

  1. Every DECLARE has a valid type (String, Integer, Boolean, Decimal, DateTime, Module.Entity, or List of Module.Entity)
  2. Entity declarations do not use = empty (only list declarations do)
  3. Every flow path ends with a RETURN statement (or the microflow returns void)
  4. No empty list variables are used as loop sources
  5. No nested loops for list matching
  6. All entity and microflow qualified names use the Module.Name format
  7. VALIDATION FEEDBACK uses the $Variable/Attribute MESSAGE 'text' syntax

Run the syntax checker to catch issues:

mxcli check script.mdl
mxcli check script.mdl -p app.mpr --references

Pages

Pages define the user interface of a Mendix application. Each page consists of a widget tree arranged within a layout, with data sources that connect widgets to the domain model.

Core Concepts

ConceptDescription
LayoutA reusable page template that defines content regions (e.g., header, sidebar, main content)
Widget treeA hierarchical structure of widgets that defines the page’s visual content
Data sourceDetermines how a widget obtains its data (page parameter, database query, microflow, etc.)
Widget nameEvery widget has a unique name within the page, used for ALTER PAGE operations. Layout-grid rows and columns, data-grid columns, and slot blocks such as a gallery’s template have none (Mendix stores none); a data-grid column is addressed as grid column(Attr)

CREATE PAGE

The basic syntax for creating a page:

CREATE [OR REPLACE] PAGE <Module>.<Name> [FOLDER '<path>']
(
  [Params: ( $Param: Module.Entity | Type [, ...] ),]
  Title: '<title>',
  Layout: <Module.LayoutName>
  [, Class: '<css-class>', Style: '<css: rule>']
)
{
  <widget-tree>
}

Minimal Example

CREATE PAGE MyModule.Home
(
  Title: 'Welcome',
  Layout: Atlas_Core.Atlas_Default
)
{
  CONTAINER cMain {
    DYNAMICTEXT txtWelcome (Content: 'Welcome to the application')
  }
};

Page with Parameters

Pages can receive entity objects or primitive values as parameters from the calling context:

CREATE PAGE MyModule.Customer_Edit
(
  Params: ( $Customer: MyModule.Customer ),
  Title: 'Edit Customer',
  Layout: Atlas_Core.PopupLayout
)
{
  DATAVIEW dvCustomer (DataSource: $Customer) {
    TEXTBOX txtName (Label: 'Name', Attribute: Name)
    TEXTBOX txtEmail (Label: 'Email', Attribute: Email)
    FOOTER {
      ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)
      ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
    }
  }
};

Page Properties

PropertyDescriptionExample
ParamsPage parameters (entity objects or primitives)Params: ( $Order: Sales.Order, $Qty: Integer )
TitlePage title shown in the browser/tabTitle: 'Edit Customer'
LayoutLayout to use for the pageLayout: Atlas_Core.PopupLayout
VariablesPage-level variables for conditional logicVariables: ( $show: Boolean = true )
ClassCSS class applied to the page (Forms$Appearance)Class: 'container-fluid bg-light'
StyleInline CSS style applied to the pageStyle: 'min-height: 100vh'

The folder is a clause after the name, not a property: CREATE PAGE MyModule.Customer_Edit FOLDER 'Pages/Customers' (…). The Folder: '…' property still parses as a deprecated alias (MDL-DEPR105); mxcli fmt --upgrade moves it.

Widget Properties

Responsive Column Widths

Layout grid columns support responsive widths for desktop, tablet, and phone:

COLUMN col1 (DesktopWidth: 8, TabletWidth: 6, PhoneWidth: 12) { ... }

Values are 1-12 (grid units), AutoFill or AutoFit (Studio Pro’s “Auto-fit content”). TabletWidth and PhoneWidth default to AutoFill when omitted.

Conditional Visibility

Any widget can be conditionally visible. The condition is a Mendix client expression, written bare and stored as written, so an attribute of the context object is $currentObject/Attr:

TEXTBOX txtName (Label: 'Name', Attribute: Name, Visible: $currentObject/IsActive)

Static values also work: Visible: false hides the widget unconditionally.

The older bracketed form, Visible: [IsActive], still parses — it roots a bare attribute in $currentObject — and warns MDL-DEPR081; mxcli fmt --upgrade rewrites it to the expression it stores. A constant condition such as Editable: [false] has no bare spelling and keeps its brackets.

Studio Pro’s “based on attribute value” form lists the values of a Boolean or enumeration attribute (of the enclosing data container’s entity) that show the widget; empty is Studio Pro’s “(empty)” choice:

CONTAINER cntRunning (Visible: Status in (Running, empty)) { ... }
TEXTBOX txtPassword (Label: 'Password', Attribute: Password, Visible: IsLocalUser in (true))

mxcli writes one condition per value of the attribute, as Studio Pro does, so a value not listed hides the widget. describe page emits this form for widgets set up that way in Studio Pro.

Studio Pro’s “Visible for module roles” restricts a widget to users holding one of the named module roles. It is a separate setting, so it combines with an expression or attribute condition — the widget then carries two Visible: lines:

mdl 1;
create page MyModule.Request_Review (Title: 'Review', Layout: Atlas_Core.Atlas_Default) {
  container cntAdmin (Visible: roles (MyModule.Administrator)) {
    dynamictext txtAdmin (Content: 'Administrators only')
  }
  actionbutton btnClose (Caption: 'Close', Action: close page,
    Visible: false, Visible: roles (MyModule.Administrator, MyModule.Editor))
};

It is accepted on widgets, tab pages and navigation-list items. mxcli check --references refuses a role the project (or the script, above the statement) does not define. alter page … set Visible changes only the condition and keeps the stored roles.

Conditional Editability

Input widgets can be conditionally editable:

TEXTBOX txtStatus (Label: 'Status', Attribute: Status, Editable: $currentObject/Status != 'Closed')

Static values: Editable: Never, Editable: Always; Editable: false and Editable: true mean Never and Always.

The condition may also be written as a quoted string, exactly as for Visible: — Editable: '$currentObject/Status != ''Closed''' stores the same condition as the bare form above (a quote inside the string is doubled). Any other value, such as a misspelt Neever, is refused by mxcli check (MDL-WIDGET19) instead of leaving the field editable.

Pluggable Widgets

A pluggable widget takes Visible: in all the forms above, and Editable: when its widget package declares the Editability system property — <systemProperty key="Editability"/> in its widget XML. The combo box declares it:

COMBOBOX cmbStatus (Label: 'Status', Attribute: Status, Editable: Never, Visible: $currentObject/Title != empty)

Visibility is available on every pluggable widget, as in Studio Pro: a declared <systemProperty key="Visibility"/> only places the setting in the widget’s own tabs, and a data grid, which declares none, still takes Visible:. A widget whose package does not declare Editability (a data grid, an image) has no such setting in Studio Pro either, so mxcli check refuses Editable: on it as MDL-WIDGET41.

Layouts

Layouts are referenced by their qualified name (Module.LayoutName). Common Atlas layouts include:

LayoutUsage
Atlas_Core.Atlas_DefaultFull-page layout with navigation sidebar
Atlas_Core.PopupLayoutModal popup dialog
Atlas_Core.Atlas_TopBarLayout with top navigation bar

DROP PAGE

Removes a page from the project:

DROP PAGE MyModule.Customer_Edit;

Inspecting Pages

Use SHOW and DESCRIBE to examine existing pages:

-- List all pages in a module
LIST PAGES IN MyModule;

-- Show the full MDL definition of a page (round-trippable)
DESCRIBE PAGE MyModule.Customer_Edit;

The output of DESCRIBE PAGE can be used as input to CREATE OR REPLACE PAGE for round-trip editing.

See Also

Page Structure

Every MDL page has three main parts: page properties (title, layout, parameters), a layout reference that defines the page skeleton, and a widget tree that fills the content area.

CREATE PAGE Syntax

CREATE [OR REPLACE] PAGE <Module>.<Name> [FOLDER '<path>']
(
  [Params: ( $Param: Module.Entity | Type [, ...] ),]
  Title: '<title>',
  Layout: <Module.LayoutName>
  [, Variables: ( $name: Type = <expression> [, ...] )]
)
{
  <widget-tree>
}

Layout Reference

The Layout property selects the page layout, which determines the overall structure (navigation sidebar, top bar, popup frame, etc.). The widget tree you define fills the layout’s content placeholder.

CREATE PAGE MyModule.CustomerList
(
  Title: 'Customers',
  Layout: Atlas_Core.Atlas_Default
)
{
  -- Widgets here fill the main content area of Atlas_Default
  DATAGRID dgCustomers (DataSource: DATABASE MyModule.Customer) {
    COLUMN (Attribute: Name, Caption: 'Name')
    COLUMN (Attribute: Email, Caption: 'Email')
  }
};

Popup layouts are typically used for edit and detail pages:

CREATE PAGE MyModule.Customer_Edit
(
  Params: ( $Customer: MyModule.Customer ),
  Title: 'Edit Customer',
  Layout: Atlas_Core.PopupLayout
)
{
  DATAVIEW dvCustomer (DataSource: $Customer) {
    TEXTBOX txtName (Label: 'Name', Attribute: Name)
    FOOTER {
      ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)
      ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
    }
  }
};

Page Parameters

Page parameters define values that must be passed when the page is opened. Parameters use the $ prefix and can be entity types or primitive types (String, Integer, Decimal, Boolean, DateTime):

(
  Params: ( $Customer: MyModule.Customer ),
  ...
)

Multiple parameters are comma-separated, and can mix entity and primitive types:

(
  Params: ( $Order: Sales.Order, $Quantity: Integer, $IsNew: Boolean ),
  ...
)

Inside the widget tree, a DATAVIEW binds to a parameter using DataSource: $ParamName.

Page Variables

Page variables store local state (booleans, strings, etc.) that can control widget visibility or other conditional logic:

(
  Title: 'Product Detail',
  Layout: Atlas_Core.Atlas_Default,
  Variables: ( $showDetails: Boolean = true )
)

Data Sources

Data sources tell a container widget (DataView, DataGrid, ListView, Gallery) where to get its data.

Page Parameter Source

Binds to a page parameter. Used by DataView widgets for edit/detail pages:

DATAVIEW dvCustomer (DataSource: $Customer) {
  -- widgets bound to Customer attributes
}

Database Source

Retrieves entities directly from the database. Optionally includes an XPath constraint:

DATAGRID dgOrders (DataSource: DATABASE Sales.Order) {
  COLUMN (Attribute: OrderId, Caption: 'Order #')
  COLUMN (Attribute: OrderDate, Caption: 'Date')
}

Microflow Source

Calls a microflow that returns a list or single object:

DATAVIEW dvDashboard (DataSource: MICROFLOW MyModule.DS_GetDashboardData) {
  -- widgets bound to the returned object's attributes
}

Nanoflow Source

Same as microflow source but calls a nanoflow (runs on the client):

LISTVIEW lvRecent (DataSource: NANOFLOW MyModule.DS_GetRecentItems) {
  -- widgets for each item
}

Association Source

Follows an association from a parent DataView’s object:

DATAVIEW dvCustomer (DataSource: $Customer) {
  LISTVIEW lvOrders (DataSource: ASSOCIATION Customer_Order) {
    -- widgets for each Order
  }
}

Selection Source

Binds to the currently selected item in another list widget:

DATAGRID dgProducts (DataSource: DATABASE MyModule.Product) {
  COLUMN (Attribute: Name)
}

DATAVIEW dvDetail (DataSource: SELECTION dgProducts) {
  TEXTBOX txtDescription (Label: 'Description', Attribute: Description)
}

Widget Tree Structure

The widget tree is a nested hierarchy. Container widgets hold child widgets within { } braces. Every widget requires a unique name — except the elements Mendix stores no name for, which take none: a layout grid’s rows and columns, a data grid’s columns and control bar, and a gallery’s template and filter. A name written there anyway is ignored and reported as MDL-DEPR005.

{
  LAYOUTGRID grid1 {
    ROW {
      COLUMN {
        TEXTBOX txtName (Label: 'Name', Attribute: Name)
      }
      COLUMN {
        TEXTBOX txtEmail (Label: 'Email', Attribute: Email)
      }
    }
  }
}

Folder Organization

Use the FOLDER clause after the name to organize pages into folders within a module:

CREATE PAGE MyModule.Customer_Edit FOLDER 'Customers'
(
  Params: ( $Customer: MyModule.Customer ),
  Title: 'Edit Customer',
  Layout: Atlas_Core.PopupLayout
)
{
  ...
}

Nested folders use / separators: FOLDER 'Pages/Customers/Detail'. Missing folders are auto-created. The Folder: '…' property is a deprecated alias of the clause (MDL-DEPR105).

See Also

Widget Types

A widget is declared with a type keyword, a unique name, properties in parentheses, and optional child widgets in braces.

WIDGET_TYPE widgetName (Property: value, ...) [{ children }]

Each built-in widget takes its own properties. A property the widget does not take — Caption: on a TEXTBOX (an input’s label is Label:), Label: on an ACTIONBUTTON, Visible: on a SNIPPETCALL (Mendix stores no visibility for one), a misspelt key — is refused under mdl 1; by check and by exec, with nothing written (MDL-WIDGET52). Without the header it is dropped as before and warned about (MDL-V1-WIDGETPROP). The message lists the properties that widget takes.

The list below is not the boundary. The built-in Mendix widgets are documented here because they have fixed, hand-written property sets. Every pluggable or custom widget installed in the project is also written by its own name, with a body derived from the widget’s definition — see Any installed widget below. If a widget is in widgets/, MDL can name it.

Widget Categories

CategoryWidgets
LayoutLAYOUTGRID, ROW, COLUMN, CONTAINER, CUSTOMCONTAINER
DataDATAVIEW, LISTVIEW, DATAGRID, GALLERY
InputTEXTBOX, TEXTAREA, CHECKBOX, RADIOBUTTONS, DATEPICKER, COMBOBOX, FILEMANAGER
DisplayDYNAMICTEXT, IMAGE, STATICIMAGE, DYNAMICIMAGE
ActionACTIONBUTTON, LINKBUTTON
NavigationNAVIGATIONLIST
StructureHEADER, FOOTER, CONTROLBAR, SNIPPETCALL

Layout Widgets

LAYOUTGRID

Creates a responsive grid with rows and columns. The primary layout mechanism for arranging widgets on a page:

LAYOUTGRID grid1 {
  ROW {
    COLUMN {
      TEXTBOX txtName (Label: 'Name', Attribute: Name)
    }
    COLUMN {
      TEXTBOX txtEmail (Label: 'Email', Attribute: Email)
    }
  }
  ROW {
    COLUMN {
      TEXTAREA txtNotes (Label: 'Notes', Attribute: Notes)
    }
  }
}

ROW

A row within a LAYOUTGRID. Contains one or more COLUMN children.

COLUMN

A column within a ROW. Contains any number of child widgets. Column width is determined by the layout grid system.

CONTAINER

A generic div container. Used to group widgets and apply CSS classes or styles:

CONTAINER cCard (Class: 'card mx-spacing-top-large') {
  DYNAMICTEXT txtTitle (Content: 'Section Title')
  TEXTBOX txtValue (Label: 'Value', Attribute: Value)
}

Properties:

PropertyDescriptionExample
ClassCSS class namesClass: 'card p-3'
StyleInline CSS stylesStyle: 'padding: 16px;'
DynamicClassesRuntime-computed CSS classes (expression; stacks on Class)DynamicClasses: if $currentObject/IsActive then 'is-active' else ''
DesignPropertiesDesign property valuesDesignProperties: ('Spacing top': 'Large')

CUSTOMCONTAINER

Similar to CONTAINER but used for custom-styled containers.

TABCONTAINER / TABPAGE

A tab container holds tab pages; each tab page holds widgets.

CREATE PAGE MyModule.Task_Tabs (
  Title: 'Tasks',
  Layout: Atlas_Core.Atlas_Default,
  Params: ($Panel: MyModule.Panel)
) {
  DATAVIEW dvPanel (DataSource: $Panel) {
    TABCONTAINER tcMain {
      TABPAGE tpOpen (Caption: 'Open', Badge: '{1}', BadgeParams: ({1} = OpenCount)) {
        DYNAMICTEXT txtOpen (Content: 'Open tasks')
      }
      TABPAGE tpDone (Caption: 'Done') {
        DYNAMICTEXT txtDone (Content: 'Done tasks')
      }
    }
  }
};
Property (tab page)DescriptionExample
CaptionTab caption, plain text (no parameters)Caption: 'Open'
BadgeText shown beside the caption, a text template like a dynamic text’s ContentBadge: '{1}'
BadgeParamsThe badge template’s parameters, resolved against the enclosing data containerBadgeParams: ({1} = OpenCount)
VisibleVisibility conditionVisible: $Panel/ShowDone

A tab page has no Class, Style or CaptionParams: Mendix stores none for it. A BadgeParams without a Badge, or a Badge that is not a quoted template, is refused (MDL-WIDGET51); Badge on any other widget is not stored, and is refused (MDL-WIDGET52).

Data Widgets

DATAVIEW

Displays a single object. The central widget for detail and edit pages. Must have a data source:

DATAVIEW dvCustomer (DataSource: $Customer) {
  TEXTBOX txtName (Label: 'Name', Attribute: Name)
  TEXTBOX txtEmail (Label: 'Email', Attribute: Email)
  COMBOBOX cbStatus (Label: 'Status', Attribute: Status)
  FOOTER {
    ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)
    ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
  }
}

Data source options:

  • DataSource: $ParamName – page parameter
  • DataSource: MICROFLOW Module.MF_Name – microflow returning a single object
  • DataSource: NANOFLOW Module.NF_Name – nanoflow returning a single object
  • DataSource: SELECTION widgetName – currently selected item from a list widget
  • DataSource: ASSOCIATION AssocName – follow an association from a parent context

DATAGRID

Displays a list of objects in a tabular format with columns, sorting, and pagination:

DATAGRID dgOrders (DataSource: DATABASE Sales.Order, PageSize: 20) {
  COLUMN (Attribute: OrderId, Caption: 'Order #')
  COLUMN (Attribute: OrderDate, Caption: 'Date')
  COLUMN (Attribute: Amount, Caption: 'Amount', Alignment: right)
  COLUMN (Attribute: Status, Caption: 'Status')
  CONTROLBAR {
    ACTIONBUTTON btnNew (Caption: 'New', Action: CALL MICROFLOW Sales.ACT_CreateOrder, ButtonStyle: Primary)
  }
}

DataGrid properties:

PropertyDescriptionExample
DataSourceData source (DATABASE, MICROFLOW, etc.)DataSource: DATABASE Module.Entity
PageSizeNumber of rows per pagePageSize: 25
PaginationPagination modePagination: virtualScrolling
PagingPositionPosition of paging controlsPagingPosition: both
ShowPagingButtonsWhen to show paging buttonsShowPagingButtons: auto
SelectionRow selection: None, Single or Multi (also on a GALLERY). The widget key itemSelection: is the same property; two different values for it are refused (MDL-WIDGET50)Selection: Single

Any other Data grid 2 setting can be set by its widget key — the name the widget’s own definition gives it (DESCRIBE WIDGET TYPE datagrid lists them with their types, defaults and allowed values). The grid’s personalization, for example, is stored in the browser or in an attribute of the surrounding object:

-- in the browser's local storage
DATAGRID dgLocal (DataSource: DATABASE Sales.Order,
                  configurationStorageType: localStorage,
                  storeFiltersInPersonalization: true)

-- in an attribute of the data view the grid sits in
DATAVIEW dvPref (DataSource: $Pref) {
  DATAGRID dgAttr (DataSource: DATABASE Sales.Order,
                   configurationStorageType: attribute,
                   configurationAttribute: Config)
}

DESCRIBE PAGE prints every such setting whose stored value differs from the widget’s default, under the same key, so describing a page and executing the description gives back the page it started from. A setting it cannot spell (an attribute reached over an association) is named in a -- NOT re-executable comment instead of being dropped without a word. Column settings with no keyword of their own (minWidth, minWidthLimit, allowEventPropagation, …) are written and described the same way, and so are a Gallery’s.

Column properties:

PropertyValuesDefaultDescription
Attributeattribute name, or association path Assoc/Attr(required)The attribute to display. Use an association path to show an attribute over a reference — e.g. Attribute: Order_Customer/Name shows the associated Customer’s Name. Association names are bare (resolved against the grid’s entity module); multi-hop paths (A/B/Attr) are supported.
Captionstringattribute nameColumn header text
Alignmentleft, center, rightleftText alignment
WrapTexttrue, falsefalseAllow text wrapping
Sortabletrue, falsevariesAllow sorting by this column
Resizabletrue, falsetrueAllow column resizing
Draggabletrue, falsetrueAllow column reordering
Hidableyes, hidden, noyesUser visibility toggle
ColumnWidthautoFill, autoFit, manualautoFillWidth mode
Sizeinteger (px)1Width in pixels (manual mode)
Visibleexpression stringtrueVisibility expression
DynamicCellClassexpression string(empty)Dynamic CSS class expression
Tooltiptext string(empty)Column tooltip

LISTVIEW

Displays a list of objects using a repeating template. More flexible than DataGrid for custom layouts:

LISTVIEW lvProducts (DataSource: DATABASE MyModule.Product) {
  CONTAINER cItem (Class: 'list-item') {
    DYNAMICTEXT txtName (Content: '{1}', Attribute: Name)
    DYNAMICTEXT txtPrice (Content: '${1}', Attribute: Price)
  }
}

Database source over an association

Inside a data container, a list view can retrieve from the database the objects an association of the context object points to. Studio Pro calls it a Database source with an entity path; it keeps an XPath constraint, a sort order and a search bar:

create snippet MyModule.TaskAssignees (Params: ($Task: System.WorkflowUserTask)) {
  listview lvAssignees (
    DataSource: database from $Task/System.WorkflowUserTask_Assignees/Administration.Account
      where [Active = true()] sort by FullName asc search by FullName
  ) {
    dynamictext txtName (Content: '{1}', ContentParams: ({1} = FullName))
  }
};

The path pairs each association with the entity it arrives at. Name the entity: it may be a specialization of the association’s own end (here Administration.Account for System.User), and DESCRIBE always prints it. A trailing association with no entity gets the end opposite the context.

It is a different source from $Task/System.WorkflowUserTask_Assignees — an association source, which follows the association in memory and has no XPath, sort or search.

A gallery, a data grid and any other pluggable widget take the same form for their “Database” source, with its XPath and sort (search by stays a list view search bar). Widgets with a source of their own, such as a chart series or a dynamic image, refuse it.

Specialization templates

When the list view’s entity is a generalization, it can render a different body per specialization. A template is identified by the entity it renders — it has no name, hence TEMPLATE FOR <entity>:

LISTVIEW vehicleListView (DataSource: DATABASE Pages.Vehicle) {
  -- the default body, used for an object no template matches
  DYNAMICTEXT defaultVehicle (Content: '{1} {2}', ContentParams: ({1} = Brand, {2} = Model))

  TEMPLATE FOR Pages.Bus {
    DYNAMICTEXT busLabel (Content: 'Bus, capacity {1}', ContentParams: ({1} = PassengerCapacity))
  }
  TEMPLATE FOR Pages.Truck {
    DYNAMICTEXT truckLabel (Content: 'Truck, max load {1} kg', ContentParams: ({1} = MaxLoadKg))
  }
}

Widgets written directly in the list view body are the default rendering. Templates keep their source order: Mendix stores and matches in that order, so it is authored rather than derived, and DESCRIBE PAGE emits them as stored.

Inside a template the context object is the specialization, so an attribute only that specialization has still resolves — PassengerCapacity above exists on Pages.Bus, not on Pages.Vehicle.

The entity must be the list view’s entity or a specialization of it, and there may be at most one template per entity; both are refused rather than written, because a template that cannot match never renders.

A Gallery’s TEMPLATE <name> is a different construct — a named content slot, not a per-specialization body.

A pluggable widget that displays items in a card/grid layout:

GALLERY galProducts (DataSource: DATABASE MyModule.Product) {
  CONTAINER cCard {
    DYNAMICTEXT txtName (Content: '{1}', Attribute: Name)
  }
}

Responsive column properties control how many columns the grid uses per breakpoint:

PropertyDescriptionDefault
DesktopColumnsNumber of columns on desktop1
TabletColumnsNumber of columns on tablet1
PhoneColumnsNumber of columns on phone1
GALLERY galBoard (
  DataSource: DATABASE MyModule.Cell,
  DesktopColumns: 9,
  TabletColumns: 4,
  PhoneColumns: 2
) {
  DYNAMICTEXT txtVal (Content: '{1}', Attribute: Value)
}

Input Widgets

All input widgets share common properties:

PropertyDescriptionExample
LabelField label text — not Caption:, which is a button’s (MDL-WIDGET52)Label: 'Customer Name'
AttributeEntity attribute to bind toAttribute: Name
EditableEditability: Always, Never, true/false, or a condition expression (bare or quoted)Editable: Never
VisibleVisibility expressionVisible: '$showField'

Each input widget takes only some attribute types. Measured with mxbuild 11.14.0, every other pairing is CE2421 (“Only attributes of type … are allowed here.”):

WidgetAttribute types
TEXTBOXString, Integer, Long, Decimal, AutoNumber (and Hashed string)
TEXTAREAString
DATEPICKERDateTime
CHECKBOXBoolean
RADIOBUTTONSBoolean, Enumeration
DROPDOWNEnumeration

With -p, check --references reports a mismatch as MDL-WIDGET39, judging the attribute at the end of an association path and on the generalization that declares it. It also reports a bare association bound where an attribute belongs (Attribute: Order_Customer), which mxbuild answers with CE1613 and which stops mx check from reporting anything else. An enumeration takes RADIOBUTTONS or COMBOBOX.

The classic drop-down (DROPDOWN, stored as Forms$DropDown) is not supported by the React client: on a project whose Web UI setting is OptimizedClient: Yes (show settings), mxbuild reports CE0582 and check --references reports MDL-WIDGET40. Use COMBOBOX over the same attribute. Under No and MigrationMode the drop-down builds, and nothing is reported. A drop-down the project already holds is reported by mxcli lint as MPR012, together with the other built-in widgets that are CE0582 under the React client: the static and dynamic image, the legacy data grid and the reference selector (measured on 11.16; the template grid and the reference set selectors are not yet measured).

TEXTBOX

Single-line text input. The most common input widget:

TEXTBOX txtName (Label: 'Name', Attribute: Name)
TEXTBOX txtEmail (Label: 'Email', Attribute: Email)

TEXTAREA

Multi-line text input for longer text:

TEXTAREA txtDescription (Label: 'Description', Attribute: Description)

CHECKBOX

Boolean (true/false) input:

CHECKBOX cbActive (Label: 'Active', Attribute: IsActive)

RADIOBUTTONS

Displays enumeration or boolean values as radio buttons:

RADIOBUTTONS rbStatus (Label: 'Status', Attribute: Status)

DATEPICKER

Date and/or time input:

DATEPICKER dpBirthDate (Label: 'Birth Date', Attribute: BirthDate)

COMBOBOX

Dropdown selection for enumeration values or associations. Uses the pluggable ComboBox widget:

COMBOBOX cbStatus (Label: 'Status', Attribute: Status)

REFERENCESELECTOR

The classic reference selector (Forms$ReferenceSelector) is a built-in Forms widget mxcli has no writer for. The keyword parses, and check refuses it as MDL-WIDGET38 rather than letting exec stop at the page. Select an associated object with a COMBOBOX over the association instead; it needs its own datasource: (CE0642 without one) and a caption attribute.

Display Widgets

DYNAMICTEXT

Displays dynamic text content, often with attribute values:

DYNAMICTEXT txtGreeting (Content: 'Welcome, {1}', Attribute: Name)

IMAGE / STATICIMAGE / DYNAMICIMAGE

Display images on a page:

-- Static image from the project
STATICIMAGE imgLogo (Image: 'MyModule.Logo')

-- Dynamic image from an entity attribute (entity must extend System.Image)
DYNAMICIMAGE imgPhoto (DataSource: $Photo, Width: 200, Height: 150)

-- Generic image widget
IMAGE imgBanner (Width: 800, Height: 200)

Image properties:

PropertyDescriptionExample
WidthWidth in pixelsWidth: 200
HeightHeight in pixelsHeight: 150

Action Widgets

ACTIONBUTTON

A button that triggers an action. The primary interactive element:

ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)
ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
ACTIONBUTTON btnDelete (Caption: 'Delete', Action: DELETE, ButtonStyle: Danger)

Action types:

ActionDescription
SAVE CHANGESCommit and close the page
CANCEL CHANGESRoll back and close the page
DELETEDelete the current object
CLOSE PAGEClose the page without saving
CALL MICROFLOW Module.MF_NameCall a microflow
CALL NANOFLOW Module.NF_NameCall a nanoflow
SHOW PAGE Module.PageNameOpen a page
CREATE OBJECT Module.Entity THEN SHOW PAGE Module.PageNameCreate an object and open a page for it
OPEN LINK 'https://…'Open a web address
SIGN OUTSign the user out
COMPLETE TASK 'Outcome'Complete a workflow user task
OPEN USER TASKOpen the user task’s page (Studio Pro’s “Show user task page”)
OPEN WORKFLOW with (DefaultPage: Module.Page)Open the workflow’s admin page, or DefaultPage when the workflow overrides none (“Show workflow admin page”)
CALL WORKFLOW Module.WorkflowStart a workflow with the data container’s object as its context

The snake-case spellings (SAVE_CHANGES, SHOW_PAGE, …) and MICROFLOW … without CALL are deprecated aliases (MDL-DEPR020).

The workflow events take their own settings in the with ( … ) list: complete task takes ClosePage and Commit (both default true), call workflow takes ClosePage (default true) and a Confirmation, open user task takes AssignOnOpen (Studio Pro’s “Auto-assign”, default true) and OpenWhenAssigned (true is “Who can open: Users with access”, default false), and open workflow requires DefaultPage (without it Mendix reports CE1834). show page takes NumberOfPagesToClose, the pages to close as it opens (a whole number, default none). open user task must sit inside a data container of System.WorkflowUserTask and open workflow inside one of System.Workflow, or mx check reports CE1843. A task inbox opens each task from its row:

DATAGRID dgTasks (DataSource: DATABASE FROM System.WorkflowUserTask, onClick: OPEN USER TASK) {
  COLUMN (Attribute: Name, Caption: 'Task')
}

Button styles:

StyleTypical appearance
DefaultStandard button
PrimaryBlue/highlighted button
SuccessGreen button
WarningYellow/amber button
DangerRed button
InfoLight blue button

Microflow action with parameters:

ACTIONBUTTON btnProcess (
  Caption: 'Process',
  Action: CALL MICROFLOW Sales.ACT_ProcessOrder(Order = $Order),
  ButtonStyle: Primary
)

Icon — Icon: 'Module.IconCollection.IconName' gives the button an icon from an icon collection (the modern Atlas icon set). The name must exist in the collection, or MxBuild rejects it (CE1613):

ACTIONBUTTON btnEdit (Caption: 'Edit', Action: PAGE App.Edit,
  Icon: 'Atlas_Core.Atlas_Filled.pencil')

LINKBUTTON

Renders as a hyperlink instead of a button. Same action types, styles, and Icon: as ACTIONBUTTON:

LINKBUTTON lnkDetails (Caption: 'View Details', Action: PAGE MyModule.Customer_Detail)
LINKBUTTON lnkEdit (Caption: 'Edit', Action: PAGE App.Edit, Icon: 'Atlas_Core.Atlas_Filled.pencil')

Structure Widgets

Header section of a DataView, placed before the main content:

DATAVIEW dvOrder (DataSource: $Order) {
  HEADER hdr1 {
    DYNAMICTEXT txtOrderTitle (Content: 'Order #{1}', Attribute: OrderId)
  }
  TEXTBOX txtStatus (Label: 'Status', Attribute: Status)
  FOOTER { ... }
}

Footer section of a DataView. Typically contains save/cancel buttons. It stores no name of its own; ALTER PAGE addresses it as <dataview>.footer:

FOOTER {
  ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)
  ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
}

CONTROLBAR

Control bar for DataGrid widgets. Contains action buttons for the grid:

CONTROLBAR bar1 {
  ACTIONBUTTON btnNew (Caption: 'New', Action: CALL MICROFLOW Module.ACT_Create, ButtonStyle: Primary)
  ACTIONBUTTON btnEdit (Caption: 'Edit', Action: PAGE Module.Entity_Edit)
  ACTIONBUTTON btnDelete (Caption: 'Delete', Action: DELETE, ButtonStyle: Danger)
}

SNIPPETCALL

Embeds a snippet (reusable page fragment) into the page:

SNIPPETCALL scNav (Snippet: MyModule.NavigationMenu)

A snippet call renders no element of its own — the snippet’s widgets are inlined into the parent. So Class, Style, DynamicClasses and DesignProperties on a snippetcall never reach the page: valid, mx check clean, and silently useless (measured on Mendix 11.15). mxcli check warns MDL-WIDGET48 (also for alter page … { set (Class: …) on <snippetcall> } and alter styling on one) and mxcli lint reports existing ones as MPR013. Style a container instead — inside the snippet, or around the call:

container cNav (class: 'sidebar') {
  snippetcall scNav (snippet: MyModule.NavigationMenu)
}

Renders a navigation list with clickable items:

NAVIGATIONLIST navMain {
  -- navigation items
}

Any installed widget

Everything above is a built-in widget: its keyword and properties are fixed by Mendix and by mxcli. A pluggable or custom widget — DataGrid 2, Combo box, Gallery, HTML Element, the charts, anything from the Marketplace, anything your team built — is named the same way, by its own MDL name:

htmlelement frame (tagName: 'div', tagContentMode: 'container') {
  attribute a1 (attributeName: 'data-testid', attributeValueType: 'expression')
  tagcontentcontainer body {
    dynamictext caption (Content: 'Inside the element')
  }
}

Three things there come from the widget’s own definition rather than from any list in mxcli:

  • The keyword htmlelement — the widget’s MDL name, which is the last segment of its widget id.
  • The properties tagName, tagContentMode — written with the widget’s own spelling, exactly as DESCRIBE WIDGET reports them.
  • The body containers attribute (an object list, one entry per repetition) and tagcontentcontainer (a child slot, holding widgets).

A property whose type in the widget’s definition is Expression — an HTML element attribute’s attributeValueExpression, a chart series’ dynamicBarColor — takes a Mendix expression written as-is:

attribute a1 (attributeName: 'data-hot', attributeValueType: 'expression',
              attributeValueExpression: if $currentObject/Hot then 'hot' else 'cold')

The same unquoted expression on a property of any other type (a text template, a plain value) is refused by check as MDL-WIDGET42 rather than written empty.

Finding the names

Ask the widget:

DESCRIBE WIDGET TYPE htmlelement;

It lists every property with its type, default and enumeration members, every body container and whether MDL can express it, and a complete example that parses and checks as written. See DESCRIBE WIDGET.

The explicit form

A widget can also be named by its full id. This is the fallback, not the norm — use it when two installed packages ship the same MDL name, or when you have the id in hand and not the name:

pluggablewidget 'com.mendix.widget.web.htmlelement.HTMLElement' frame (
  tagName: 'div'
)

DESCRIBE PAGE emits the short form wherever it round-trips, and falls back to the id form when the name would be ambiguous.

When a widget is not found

A name that resolves to no installed definition is an error, not a silently accepted widget — MDL-WIDGET25, with the nearest known names suggested. A container keyword the parent widget does not declare is MDL-WIDGET26. Both need a project open (-p), since without one mxcli knows only its embedded widgets.

A third, MDL-WIDGET29, needs no project: statictext writes Forms$Text, and Mendix has no such type. That is not a build error but a load error — mx check and Studio Pro both stop at TypeCacheUnknownTypeException before any validation runs, so the page cannot even be opened to repair it. Use DYNAMICTEXT with a literal Content:.

If a widget you have installed is not found, its definition has not been extracted yet:

mxcli widget init -p app.mpr     # extract definitions for every widget in widgets/

Binding a text-template property

Many pluggable widgets expose text-template properties — an Image’s ImageUrl and AlternativeText, a TreeNode’s headerCaption, a Timeline’s title / description / timeIndication. They take text, so a bare value is stored as a literal and renders the same string on every row, with check, exec and mx check all clean.

Bind one with the property’s own <Name>Params companion:

image cardImage (
  ImageType: imageUrl,
  ImageUrl: '{1}',        ImageUrlParams: ({1} = PictureUrl),
  AlternativeText: '{1}', AlternativeTextParams: ({1} = Name)
)

The companion is the property’s own name + Params, in whichever spelling the template itself was written. It takes the same per-parameter format (...) block a dynamictext does, and DESCRIBE PAGE emits both the template and its companion — for the Image and for every other pluggable widget — so describe → exec keeps the binding. Before ako/mxcli#575 the generic describe path read attribute references and primitives only, so a TreeNode’s headerCaption and a Timeline’s title were missing from its output altogether.

Two shorter spellings remain:

SpellingUse it for
'{AttrName}'one attribute, no formatting block
contentparams: (...)a widget with a single text template — it is one list shared by every template on the widget

Every {N} needs a matching parameter (Mendix rejects a shortfall with CE0720), and parameters with no {N} to fill are reported as MDL-WIDGET21 rather than dropped in silence.

Common Widget Properties

These properties are shared across many widget types:

PropertyDescriptionExample
ClassCSS class namesClass: 'card p-3'
StyleInline CSS stylesStyle: 'margin-top: 8px;'
DynamicClassesRuntime-computed CSS classes (expression; stacks on Class)DynamicClasses: if $currentObject/IsActive then 'is-active' else ''
DesignPropertiesAtlas design propertiesDesignProperties: ('Spacing top': 'Large', 'Full width': ON)
VisibleVisibility expressionVisible: '$showSection'
EditableEditability: Always, Never, true/false, or a condition expression (bare or quoted)Editable: Never

See Also

Data Binding

Data binding connects widgets to entity attributes and data sources. In MDL, binding is configured through widget properties rather than a special operator.

Attribute Binding

Input widgets bind to entity attributes using the Attribute property. The attribute name is resolved relative to the containing DataView’s entity context:

DATAVIEW dvCustomer (DataSource: $Customer) {
  -- These attribute names are resolved against MyModule.Customer
  TEXTBOX txtName (Label: 'Name', Attribute: Name)
  TEXTBOX txtEmail (Label: 'Email', Attribute: Email)
  CHECKBOX cbActive (Label: 'Active', Attribute: IsActive)
  DATEPICKER dpCreated (Label: 'Created', Attribute: CreatedDate)
  COMBOBOX cbStatus (Label: 'Status', Attribute: Status)
}

The SDK automatically resolves short attribute names to fully qualified paths. For example, Name is resolved to MyModule.Customer.Name based on the DataView’s entity context.

Data Sources

Data sources determine where a container widget gets its data. Different widget types support different data source types.

Page Parameter Source

The simplest binding – connects a DataView to a page parameter:

CREATE PAGE MyModule.Customer_Edit
(
  Params: ( $Customer: MyModule.Customer ),
  Title: 'Edit Customer',
  Layout: Atlas_Core.PopupLayout
)
{
  DATAVIEW dvCustomer (DataSource: $Customer) {
    TEXTBOX txtName (Label: 'Name', Attribute: Name)
  }
};

Database Source

Retrieves entities directly from the database. Works with list widgets (DataGrid, ListView, Gallery):

DATAGRID dgCustomers (DataSource: DATABASE MyModule.Customer) {
  COLUMN (Attribute: Name, Caption: 'Customer Name')
  COLUMN (Attribute: Email, Caption: 'Email')
}

Microflow Source

Calls a microflow to retrieve data. The microflow must return the appropriate type (single object for DataView, list for DataGrid/ListView):

-- Single object for a DataView
DATAVIEW dvStats (DataSource: MICROFLOW MyModule.DS_GetStatistics) {
  DYNAMICTEXT txtCount (Content: 'Total: {1}', Attribute: TotalCount)
}

-- List for a DataGrid
DATAGRID dgFiltered (DataSource: MICROFLOW MyModule.DS_GetFilteredOrders) {
  COLUMN (Attribute: OrderId)
}

Nanoflow Source

Same as microflow source but runs on the client side:

LISTVIEW lvRecent (DataSource: NANOFLOW MyModule.DS_GetRecentItems) {
  DYNAMICTEXT txtItem (Content: '{1}', Attribute: Name)
}

Association Source

Follows an association from a parent DataView to retrieve related objects:

DATAVIEW dvCustomer (DataSource: $Customer) {
  TEXTBOX txtName (Label: 'Name', Attribute: Name)

  -- Follow Customer_Order association to list orders
  LISTVIEW lvOrders (DataSource: ASSOCIATION Customer_Order) {
    DYNAMICTEXT txtOrderDate (Content: '{1}', Attribute: OrderDate)
    DYNAMICTEXT txtAmount (Content: '${1}', Attribute: Amount)
  }
}

Selection Source

Binds to the currently selected item in another list widget. Enables master-detail patterns:

-- Master: list of products
DATAGRID dgProducts (DataSource: DATABASE MyModule.Product) {
  COLUMN (Attribute: Name)
  COLUMN (Attribute: Price)
}

-- Detail: shows the selected product
DATAVIEW dvDetail (DataSource: SELECTION dgProducts) {
  TEXTBOX txtDescription (Label: 'Description', Attribute: Description)
  TEXTBOX txtCategory (Label: 'Category', Attribute: Category)
}

Nested Data Contexts

Widgets inherit the data context from their parent container. A DataView inside a DataView creates a nested context:

DATAVIEW dvOrder (DataSource: $Order) {
  TEXTBOX txtOrderId (Label: 'Order #', Attribute: OrderId)

  -- Nested DataView for the order's customer (via association)
  DATAVIEW dvCustomer (DataSource: ASSOCIATION Order_Customer) {
    -- Attributes here resolve against Customer
    DYNAMICTEXT txtCustomerName (Content: '{1}', Attribute: Name)
  }
}

An input can name the data view it reads through: Attribute: $dvOrder.OrderId. Studio Pro stores that as a widget-scoped variable (the data view, plus the parameter the data view shows), and DESCRIBE prints it back in this form. A dynamic-text parameter reads through a data view the same way, ContentParams: ({1} = $dvOrder.OrderId). The $name must be a data view that encloses the widget (Studio Pro reports CE7001 for any other), and the input must be a textbox, textarea, checkbox, datepicker, radiobuttons or dropdown — other widgets bind by attribute name:

DATAVIEW dvOrder (DataSource: $Order) {
  TEXTBOX txtOrderId (Label: 'Order #', Attribute: $dvOrder.OrderId)
  DYNAMICTEXT txtHeader (Content: 'Order {1}', ContentParams: ({1} = $dvOrder.OrderId))
}

Dynamic Text Content

The DYNAMICTEXT widget uses {1}, {2}, etc. as placeholders for attribute values in the Content property. The Attribute property specifies which attribute fills the first placeholder:

DYNAMICTEXT txtPrice (Content: 'Price: ${1}', Attribute: Price)

Button Actions with Parameters

Action buttons can pass the current data context to microflows and pages:

DATAVIEW dvOrder (DataSource: $Order) {
  ACTIONBUTTON btnProcess (
    Caption: 'Process Order',
    Action: MICROFLOW Sales.ACT_ProcessOrder(Order = $Order),
    ButtonStyle: Primary
  )

  ACTIONBUTTON btnEdit (
    Caption: 'Edit',
    Action: PAGE Sales.Order_Edit
  )
}

See Also

Snippets

Snippets are reusable page fragments that can be embedded in multiple pages. They allow you to define a widget tree once and include it wherever needed, promoting consistency and reducing duplication.

CREATE SNIPPET

CREATE [OR REPLACE] SNIPPET <Module>.<Name> [FOLDER '<path>']
[(
  Params: ( $Param: Module.Entity [, ...] )
)]
{
  <widget-tree>
}

Basic Snippet

A snippet without parameters:

CREATE SNIPPET MyModule.Footer FOLDER 'Snippets'
{
  CONTAINER cFooter (Class: 'app-footer') {
    DYNAMICTEXT txtCopyright (Content: '2024 My Company. All rights reserved.')
  }
};

Snippet with Parameters

Snippets can accept entity parameters, similar to pages:

CREATE SNIPPET MyModule.CustomerCard
(
  Params: ( $Customer: MyModule.Customer )
)
{
  CONTAINER cCard (Class: 'card') {
    DATAVIEW dvCustomer (DataSource: $Customer) {
      DYNAMICTEXT txtName (Content: '{1}', Attribute: Name)
      DYNAMICTEXT txtEmail (Content: '{1}', Attribute: Email)
      ACTIONBUTTON btnEdit (
        Caption: 'Edit',
        Action: SHOW PAGE MyModule.Customer_Edit,
        ButtonStyle: Primary
      )
    }
  }
};

Using Snippets in Pages

Embed a snippet in a page using the SNIPPETCALL widget:

CREATE PAGE MyModule.Home
(
  Title: 'Home',
  Layout: Atlas_Core.Atlas_Default
)
{
  CONTAINER cMain {
    SNIPPETCALL scFooter (Snippet: MyModule.Footer)
  }
};

A snippet with parameters takes its arguments in Params:, one Param = $variable each. The variable is a parameter of the calling page, or — for a call inside another snippet — a parameter of that snippet. A parameter the enclosing data context satisfies is left out:

SNIPPETCALL scCard (Snippet: MyModule.CustomerCard, Params: (Customer = $Customer))

DESCRIBE prints the arguments in this form, so a snippet call keeps its mappings through describe and exec.

Inspecting Snippets

-- List all snippets in a module
LIST SNIPPETS IN MyModule;

-- View full MDL definition
DESCRIBE SNIPPET MyModule.CustomerCard;

DROP SNIPPET

DROP SNIPPET MyModule.Footer;

ALTER SNIPPET

Snippets support the same in-place modification operations as pages. See ALTER PAGE / ALTER SNIPPET:

ALTER SNIPPET MyModule.CustomerCard {
  SET (Caption: 'View Details') ON btnEdit;
  INSERT AFTER txtEmail {
    DYNAMICTEXT txtPhone (Content: '{1}', Attribute: Phone)
  };
  INSERT INTO ctnActions {   -- append as the container's last child
    ACTIONBUTTON btnMore (Caption: 'More', Action: NOTHING)
  }
};

See Also

ALTER PAGE / ALTER SNIPPET

The ALTER PAGE and ALTER SNIPPET statements modify an existing page or snippet’s widget tree in-place, without requiring a full CREATE OR REPLACE. This is especially useful for incremental changes: adding a field, changing a button caption, or removing an unused widget.

ALTER operates directly on the raw widget tree, preserving any widget types that MDL does not natively support (pluggable widgets, custom widgets, etc.).

Syntax

ALTER PAGE <Module>.<Name> {
  <operations>
};

ALTER SNIPPET <Module>.<Name> {
  <operations>
};

Operations

SET – Modify Widget Properties

Change one or more properties on a widget identified by name:

mdl 1;
-- Single property
ALTER PAGE Module.EditPage {
  SET (Caption: 'Save & Close') ON btnSave
};

-- Multiple properties at once
ALTER PAGE Module.EditPage {
  SET (Caption: 'Save & Close', ButtonStyle: Success) ON btnSave
};

Supported SET properties:

PropertyDescriptionExample
CaptionButton/link captionSET (Caption: 'Submit') ON btnSave
ActionOn-click action of a button, link button, container, list view or image — any CREATE PAGE action expressionSET (Action: CALL MICROFLOW M.ACT_Open) ON card1
LabelInput field labelSET (Label: 'Full Name') ON txtName
RenderModeDynamic text rendering: Text, Paragraph, H1–H6SET (RenderMode: H2) ON txtTitle
PlaceholderInput hint text (text box, text area, date picker)SET (Placeholder: 'Enter your name') ON txtName
PasswordMask a text box’s inputSET (Password: true) ON txtPin
ValidationA text box’s validation expressionSET (Validation: '$value != empty') ON txtName
ValidationMessageMessage shown when the validation failsSET (ValidationMessage: 'Name is required') ON txtName
ButtonStyleButton visual styleSET (ButtonStyle: Danger) ON btnDelete
ClassCSS class namesSET (Class: 'card p-3') ON cMain
StyleInline CSSSET (Style: 'margin: 8px;') ON cBox
DynamicClassesRuntime-computed CSS classesSET (DynamicClasses: if $currentObject/IsActive then 'is-active' else '') ON cMain
EditableEditability: Always, Never, or a bare condition expression (the quoted form is refused in SET)SET (Editable: Never) ON txtEmail
VisibleVisibility expressionSET (Visible: '$showField') ON txtPhone
NameWidget nameSET (Name: 'txtFullName') ON txtName

SET – Page-Level Properties

Omit the ON clause to set page-level properties. These names are case-sensitive: Title, Documentation, Class, Style, PopupWidth, PopupHeight, PopupResizable.

Documentation is the same property the /** … */ doc comment on CREATE PAGE writes. Before it was settable here, documenting an existing page meant re-running its create — which for a real page means re-emitting its whole widget tree. Setting it to '' clears it.

ALTER PAGE Module.EditPage {
  SET (Title: 'Customer Details');
  SET (Class: 'container-fluid bg-light');  -- page CSS class (Forms$Appearance)
  SET (Style: 'min-height: 100vh')          -- page inline style
};

SET – Pluggable Widget Properties

Use quoted property names to set properties on pluggable widgets (ComboBox, DataGrid2, etc.):

ALTER PAGE Module.EditPage {
  SET ('showLabel': false) ON cbStatus
};

SET Layout – Change Page Layout

Switch a page’s layout without rebuilding the widget tree. All widget content is preserved – only the layout reference and placeholder mappings are updated.

mdl 1;
-- Auto-map placeholders by name (common case)
ALTER PAGE Module.EditPage {
  SET Layout = Atlas_Core.Atlas_Default
};

-- Explicit mapping when placeholder names differ
ALTER PAGE Module.EditPage {
  SET Layout = Atlas_Core.Atlas_SideBar MAP (Main AS Content, Extra AS Sidebar)
};

When both the old and new layouts share the same placeholder names (e.g., both have Main), no MAP clause is needed – placeholders are matched automatically. Use MAP when the new layout has different placeholder names.

Not supported for snippets (snippets don’t have layouts).

INSERT – Add Widgets

Insert new widgets before or after an existing widget:

mdl 1;
-- Insert after a widget
ALTER PAGE Module.EditPage {
  INSERT AFTER txtEmail {
    TEXTBOX txtPhone (Label: 'Phone', Attribute: Phone)
    TEXTBOX txtFax (Label: 'Fax', Attribute: Fax)
  }
};

-- Insert before a widget
ALTER PAGE Module.EditPage {
  INSERT BEFORE btnSave {
    ACTIONBUTTON btnPreview (Caption: 'Preview', Action: CALL MICROFLOW Module.ACT_Preview)
  }
};

-- Insert INTO a container — append as its last child (works on an empty container)
ALTER PAGE Module.EditPage {
  INSERT INTO ctnToolbar {
    ACTIONBUTTON btnNew (Caption: 'New', Action: NOTHING, ButtonStyle: Primary)
  }
};

INSERT INTO <container> appends widgets as the container’s last children — the only way to fill an empty container, and handy for adding to a container or data view without a sibling to anchor to. Widgets inserted into a data view take that data view’s entity. Supported on simple containers; for a layout grid or tab container, insert relative to a widget inside the target column/tab instead.

The inserted widgets use the same syntax as in CREATE PAGE. Multiple widgets can be inserted in a single block.

DROP – Remove Widgets

Remove one or more widgets by name:

mdl 1;
ALTER PAGE Module.EditPage {
  DROP txtUnused
};

-- Multiple widgets
ALTER PAGE Module.EditPage {
  DROP txtFax, txtPager, btnObsolete
};

Dropping a container widget also removes all of its children.

REPLACE – Replace a Widget

Replace a widget (and its subtree) with new widgets:

ALTER PAGE Module.EditPage {
  REPLACE txtOldField WITH {
    TEXTAREA txtNotes (Label: 'Notes', Attribute: Notes)
  }
};

Page Variables

Add or remove page-level variables:

mdl 1;
-- Add a variable
ALTER PAGE Module.EditPage {
  ADD Variables $showAdvanced: Boolean = false
};

-- Remove a variable
ALTER PAGE Module.EditPage {
  DROP Variables $showAdvanced
};

Parameters

Add or remove a page or snippet parameter without rewriting the page. A parameter is declared exactly as in CREATE PAGE’s Params: — an entity or a primitive (String, Integer, Long, Decimal, Boolean, DateTime). A snippet parameter must be an entity (MDL087; mxbuild reports CE0046).

mdl 1;
-- A page with a Url needs a {Name} segment for every parameter (CE5601), so
-- set the URL in the same statement. A page with no Url needs no SET.
ALTER PAGE Module.Customer_Edit {
  SET (Url: 'customer-edit/{Customer}/{Order}');
  ADD Parameters $Order: Module.Order;
};

-- Remove a parameter. Refused while a data source or an expression on the
-- page still uses it.
ALTER PAGE Module.Customer_Edit {
  DROP Parameters $Order;
};

Callers are not updated: a page that opens this one, or a page that places this snippet, has to pass the new parameter, and mxcli docker check reports the ones that do not.

Combining Operations

Multiple operations can be combined in a single ALTER statement. They are applied in order:

ALTER PAGE Module.Customer_Edit {
  -- Change button appearance
  SET (Caption: 'Save & Close', ButtonStyle: Success) ON btnSave;

  -- Remove unused fields
  DROP txtFax;

  -- Add new fields after email
  INSERT AFTER txtEmail {
    TEXTBOX txtPhone (Label: 'Phone', Attribute: Phone)
    TEXTBOX txtMobile (Label: 'Mobile', Attribute: Mobile)
  };

  -- Replace old status dropdown with combobox
  REPLACE ddStatus WITH {
    COMBOBOX cbStatus (Label: 'Status', Attribute: Status)
  }
};

Workflow Tips

  1. Discover widget names first – Run DESCRIBE PAGE Module.PageName to see the current widget tree with all widget names.

  2. Use ALTER for small changes – For adding a field or changing a caption, ALTER is faster and safer than CREATE OR REPLACE, because it preserves widgets that MDL cannot round-trip (pluggable widgets with complex configurations). CREATE OR REPLACE / CREATE OR MODIFY keeps a pluggable widget exactly as stored only while its statement is unchanged from what DESCRIBE prints for it; editing any property of that widget rebuilds it from its template, and resets what MDL cannot express (translations, unmapped properties).

  3. Use CREATE OR REPLACE for major rewrites – When restructuring the entire page layout, a full replacement is cleaner.

Examples

Add a Field to an Edit Page

mdl 1;
-- First, check what's on the page
DESCRIBE PAGE MyModule.Customer_Edit;

-- Add a phone field after email
ALTER PAGE MyModule.Customer_Edit {
  INSERT AFTER txtEmail {
    TEXTBOX txtPhone (Label: 'Phone Number', Attribute: Phone)
  }
};

Change Button Behavior

ALTER PAGE MyModule.Order_Edit {
  SET (Caption: 'Submit Order', ButtonStyle: Success) ON btnSave;
  SET (Caption: 'Discard') ON btnCancel
};

DataGrid Column Operations

Mendix stores no name on a DataGrid 2 column, so DESCRIBE PAGE prints none and a column is addressed by what it shows: grid column(Attr) for the column bound to Attr (written as describe writes it, Owner/Name over an association), or grid column('Caption') for the column with that caption.

mdl 1;
-- Add a column after an existing one
ALTER PAGE MyModule.Customer_Overview {
  INSERT AFTER dgCustomers column(Email) {
    COLUMN (Attribute: Phone, Caption: 'Phone')
  }
};

-- Remove a column
ALTER PAGE MyModule.Customer_Overview {
  DROP dgCustomers column('Old column')
};

-- Change a column's caption
ALTER PAGE MyModule.Customer_Overview {
  SET (Caption: 'E-mail Address') ON dgCustomers column(Email)
};

-- Replace a column
ALTER PAGE MyModule.Customer_Overview {
  REPLACE dgCustomers column(Notes) WITH {
    COLUMN (Attribute: Description, Caption: 'Description')
  }
};

Two columns over the same attribute, or with the same caption, share the address. ALTER refuses it and lists the matches; @n picks one: DROP dgCustomers column(Email)@2.

The older dotted form dgCustomers.Email still works. It matches a name mxcli derives — the attribute’s short name, else the sanitized caption, else colN by position — which describe no longer prints.

See Also

Common Patterns

This page collects frequently used page patterns: overview/list pages, edit pages, and master-detail layouts. Each pattern is a complete, working example.

Overview / List Page

An overview page displays a list of entities with a control bar for creating, editing, and deleting records. Clicking a row opens the edit page.

CREATE PAGE MyModule.Customer_Overview FOLDER 'Customers'
(
  Title: 'Customers',
  Layout: Atlas_Core.Atlas_Default
)
{
  DATAGRID dgCustomers (DataSource: DATABASE MyModule.Customer, PageSize: 20, itemSelection: Single) {
    COLUMN (Attribute: Name, Caption: 'Name')
    COLUMN (Attribute: Email, Caption: 'Email')
    COLUMN (Attribute: Status, Caption: 'Status')
    COLUMN (Attribute: CreatedDate, Caption: 'Created')
    CONTROLBAR {
      ACTIONBUTTON btnNew (
        Caption: 'New Customer',
        Action: CALL MICROFLOW MyModule.ACT_Customer_New,
        ButtonStyle: Primary
      )
      ACTIONBUTTON btnEdit (Caption: 'Edit', Action: SHOW PAGE MyModule.Customer_Edit)
      ACTIONBUTTON btnDelete (Caption: 'Delete', Action: DELETE $dgCustomers, ButtonStyle: Danger)
    }
  }
};

The “New Customer” button calls a microflow that creates a new Customer object and opens the edit page:

CREATE MICROFLOW MyModule.ACT_Customer_New
BEGIN
  DECLARE $Customer MyModule.Customer;
  $Customer = CREATE MyModule.Customer (
    IsActive = true
  );
  SHOW PAGE MyModule.Customer_Edit (Customer = $Customer);
  RETURN $Customer;
END;

Edit Page (Popup)

An edit page displayed as a popup dialog. Receives the entity as a page parameter:

CREATE PAGE MyModule.Customer_Edit FOLDER 'Customers'
(
  Params: ( $Customer: MyModule.Customer ),
  Title: 'Edit Customer',
  Layout: Atlas_Core.PopupLayout
)
{
  DATAVIEW dvCustomer (DataSource: $Customer) {
    TEXTBOX txtName (Label: 'Name', Attribute: Name)
    TEXTBOX txtEmail (Label: 'Email', Attribute: Email)
    TEXTBOX txtPhone (Label: 'Phone', Attribute: Phone)
    COMBOBOX cbStatus (Label: 'Status', Attribute: Status)
    CHECKBOX cbActive (Label: 'Active', Attribute: IsActive)
    FOOTER {
      ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)
      ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
    }
  }
};

Detail Page (Full Page)

A full-page detail view with sections organized using layout grids:

CREATE PAGE MyModule.Customer_Detail FOLDER 'Customers'
(
  Params: ( $Customer: MyModule.Customer ),
  Title: 'Customer Detail',
  Layout: Atlas_Core.Atlas_Default
)
{
  DATAVIEW dvCustomer (DataSource: $Customer) {
    LAYOUTGRID grid1 {
      ROW {
        COLUMN {
          TEXTBOX txtName (Label: 'Name', Attribute: Name)
          TEXTBOX txtEmail (Label: 'Email', Attribute: Email)
        }
        COLUMN {
          TEXTBOX txtPhone (Label: 'Phone', Attribute: Phone)
          COMBOBOX cbStatus (Label: 'Status', Attribute: Status)
        }
      }
      ROW {
        COLUMN {
          TEXTAREA txtNotes (Label: 'Notes', Attribute: Notes)
        }
      }
    }
    FOOTER {
      ACTIONBUTTON btnEdit (
        Caption: 'Edit',
        Action: SHOW PAGE MyModule.Customer_Edit,
        ButtonStyle: Primary
      )
      ACTIONBUTTON btnBack (Caption: 'Back', Action: CLOSE PAGE)
    }
  }
};

Master-Detail Pattern

A master-detail page shows a list on one side and the selected item’s details on the other. The SELECTION data source connects the detail view to the list.

Side-by-Side Layout

CREATE PAGE MyModule.Product_MasterDetail FOLDER 'Products'
(
  Title: 'Products',
  Layout: Atlas_Core.Atlas_Default
)
{
  LAYOUTGRID gridMain {
    ROW {
      COLUMN {
        DATAGRID dgProducts (DataSource: DATABASE MyModule.Product, PageSize: 15) {
          COLUMN (Attribute: Name, Caption: 'Product')
          COLUMN (Attribute: Price, Caption: 'Price', Alignment: right)
          COLUMN (Attribute: Category, Caption: 'Category')
          CONTROLBAR {
            ACTIONBUTTON btnNew (
              Caption: 'New',
              Action: CALL MICROFLOW MyModule.ACT_Product_New,
              ButtonStyle: Primary
            )
          }
        }
      }
      COLUMN {
        DATAVIEW dvDetail (DataSource: SELECTION dgProducts) {
          TEXTBOX txtName (Label: 'Name', Attribute: Name)
          TEXTBOX txtDescription (Label: 'Description', Attribute: Description)
          TEXTBOX txtPrice (Label: 'Price', Attribute: Price)
          COMBOBOX cbCategory (Label: 'Category', Attribute: Category)
          FOOTER {
            ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)
          }
        }
      }
    }
  }
};

A master-detail pattern where selecting an entity also shows its related child entities via an association:

CREATE PAGE MyModule.Order_MasterDetail FOLDER 'Orders'
(
  Title: 'Orders',
  Layout: Atlas_Core.Atlas_Default
)
{
  LAYOUTGRID gridMain {
    ROW {
      COLUMN {
        DATAGRID dgOrders (DataSource: DATABASE MyModule.Order, PageSize: 10) {
          COLUMN (Attribute: OrderId, Caption: 'Order #')
          COLUMN (Attribute: OrderDate, Caption: 'Date')
          COLUMN (Attribute: Status, Caption: 'Status')
          COLUMN (Attribute: TotalAmount, Caption: 'Total', Alignment: right)
        }
      }
      COLUMN {
        DATAVIEW dvOrder (DataSource: SELECTION dgOrders) {
          DYNAMICTEXT txtOrderInfo (Content: 'Order #{1}', Attribute: OrderId)
          TEXTBOX txtStatus (Label: 'Status', Attribute: Status)

          -- Order lines via association
          DATAGRID dgLines (DataSource: ASSOCIATION Order_OrderLine) {
            COLUMN (Attribute: ProductName, Caption: 'Product')
            COLUMN (Attribute: Quantity, Caption: 'Qty', Alignment: right)
            COLUMN (Attribute: LineTotal, Caption: 'Total', Alignment: right)
          }

          FOOTER {
            ACTIONBUTTON btnEdit (
              Caption: 'Edit Order',
              Action: SHOW PAGE MyModule.Order_Edit,
              ButtonStyle: Primary
            )
          }
        }
      }
    }
  }
};

CRUD Page Set

A complete set of pages for managing an entity typically includes:

  1. Overview page – DataGrid with list of records + New/Edit/Delete buttons
  2. Edit page (popup) – DataView with input fields + Save/Cancel
  3. New microflow – Creates a blank object and opens the edit page

Here is a concise CRUD set for an Employee entity:

-- 1. Overview page
CREATE PAGE HR.Employee_Overview FOLDER 'Employees'
(
  Title: 'Employees',
  Layout: Atlas_Core.Atlas_Default
)
{
  DATAGRID dgEmployees (DataSource: DATABASE HR.Employee, PageSize: 20, itemSelection: Single) {
    COLUMN (Attribute: FullName, Caption: 'Name')
    COLUMN (Attribute: Department, Caption: 'Department')
    COLUMN (Attribute: HireDate, Caption: 'Hire Date')
    CONTROLBAR {
      ACTIONBUTTON btnNew (
        Caption: 'New Employee',
        Action: CALL MICROFLOW HR.ACT_Employee_New,
        ButtonStyle: Primary
      )
      ACTIONBUTTON btnEdit (Caption: 'Edit', Action: SHOW PAGE HR.Employee_Edit)
      ACTIONBUTTON btnDelete (Caption: 'Delete', Action: DELETE $dgEmployees, ButtonStyle: Danger)
    }
  }
};

-- 2. Edit page (popup)
CREATE PAGE HR.Employee_Edit FOLDER 'Employees'
(
  Params: ( $Employee: HR.Employee ),
  Title: 'Edit Employee',
  Layout: Atlas_Core.PopupLayout
)
{
  DATAVIEW dvEmployee (DataSource: $Employee) {
    TEXTBOX txtName (Label: 'Full Name', Attribute: FullName)
    TEXTBOX txtDept (Label: 'Department', Attribute: Department)
    DATEPICKER dpHireDate (Label: 'Hire Date', Attribute: HireDate)
    TEXTBOX txtEmail (Label: 'Email', Attribute: Email)
    FOOTER {
      ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)
      ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
    }
  }
};

-- 3. New employee microflow
CREATE MICROFLOW HR.ACT_Employee_New
BEGIN
  DECLARE $Employee HR.Employee;
  $Employee = CREATE HR.Employee (
    HireDate = [%CurrentDateTime%]
  );
  SHOW PAGE HR.Employee_Edit (Employee = $Employee);
  RETURN $Employee;
END;

Dashboard Page

A dashboard page using layout grids to arrange multiple data sections:

CREATE PAGE MyModule.Dashboard FOLDER 'Dashboard'
(
  Title: 'Dashboard',
  Layout: Atlas_Core.Atlas_Default
)
{
  LAYOUTGRID gridDash {
    ROW {
      COLUMN {
        CONTAINER cRecent (Class: 'card') {
          DYNAMICTEXT txtRecentTitle (Content: 'Recent Orders')
          DATAGRID dgRecent (DataSource: MICROFLOW MyModule.DS_RecentOrders, PageSize: 5) {
            COLUMN (Attribute: OrderId, Caption: 'Order #')
            COLUMN (Attribute: OrderDate, Caption: 'Date')
            COLUMN (Attribute: Status, Caption: 'Status')
          }
        }
      }
      COLUMN {
        CONTAINER cStats (Class: 'card') {
          DATAVIEW dvStats (DataSource: MICROFLOW MyModule.DS_GetStatistics) {
            DYNAMICTEXT txtTotal (Content: 'Total Orders: {1}', Attribute: TotalOrders)
            DYNAMICTEXT txtRevenue (Content: 'Revenue: ${1}', Attribute: TotalRevenue)
          }
        }
      }
    }
  }
};

See Also

Security

Mendix applications use a layered security model with three levels: project security settings, role definitions, and access rules. MDL provides complete control over all three layers.

Security Levels

The project security level determines how strictly the runtime enforces access rules.

LevelMDL KeywordDescription
OffOFFNo security enforcement (development only)
PrototypePROTOTYPESecurity enforced but incomplete configurations allowed
ProductionPRODUCTIONFull enforcement, all access rules must be complete
ALTER APP SECURITY ( SecurityLevel: PRODUCTION );

Security Architecture

Security in Mendix is organized in layers:

  1. Module Roles – defined per module, they represent permissions within that module (e.g., Shop.Admin, Shop.Viewer)
  2. User Roles – project-level roles that aggregate module roles across modules (e.g., AppAdmin combines Shop.Admin and System.Administrator)
  3. Entity Access – CRUD permissions and XPath constraints per entity per module role
  4. Document Access – execute/view permissions on microflows, nanoflows, and pages per module role
  5. Demo Users – test accounts with assigned user roles for development

Inspecting Security

MDL provides several commands for viewing the current security configuration:

-- Project-wide settings
DESCRIBE APP SECURITY;

-- Roles
LIST MODULE ROLES;
LIST MODULE ROLES IN Shop;
LIST USER ROLES;

-- Access rules
LIST ACCESS ON MICROFLOW Shop.ACT_ProcessOrder;
LIST ACCESS ON PAGE Shop.Order_Edit;
LIST ACCESS ON ENTITY Shop.Customer;
LIST ACCESS ON Shop.Customer;         -- a bare name means the entity

-- Full matrix
DESCRIBE SECURITY MATRIX;
DESCRIBE SECURITY MATRIX IN Shop;

-- Demo users
LIST DEMO USERS;

Modifying Project Security

Toggle the security level and demo user visibility:

mdl 1;
ALTER APP SECURITY ( SecurityLevel: PRODUCTION );
ALTER APP SECURITY ( EnableDemoUsers: TRUE );
ALTER APP SECURITY ( EnableDemoUsers: FALSE );

Guest (Anonymous) Access

Guest access is Studio Pro’s “Anonymous users” setting: it lets people use part of the app without signing in. It is a flag plus a user role, and the role carries the weight – whatever it can read is public.

mdl 1;
-- The role anonymous visitors are given. System.User is what lets an
-- unauthenticated session exist at all.
CREATE USER ROLE Anonymous ( ModuleRoles: (Shop.Viewer, System.User) );

ALTER APP SECURITY ( EnableGuestAccess: TRUE, GuestUserRole: Anonymous );

-- Grant exactly what should be public, and nothing else. Access is granted
-- to a module role (Shop.Viewer), never to the user role itself.
GRANT read * ON ENTITY Shop.Product TO Shop.Viewer;

Turning it off keeps the stored role, so switching it back on needs no ROLE clause:

mdl 1;
ALTER APP SECURITY ( EnableGuestAccess: FALSE );
ALTER APP SECURITY ( EnableGuestAccess: TRUE );

The role is mandatory

Mendix fails the build with CE0133 – “No user role for anonymous users selected even though the feature anonymous users is enabled” – when guest access is on and no role is set. EnableGuestAccess: TRUE is therefore refused unless a role is given in the statement or already stored in the project:

Error: EnableGuestAccess: true requires a role: no anonymous user role is configured,
and Mendix rejects anonymous access without one (CE0133).

mxcli validates the role; Mendix does not

An anonymous role that does not exist builds with zero extra errors in Mendix – the app is valid, and anonymous visitors simply get nothing. That makes a typo invisible until someone opens the public page, so mxcli checks the name against the project’s user roles and refuses an unknown one:

Error: user role not found: Anonymus (project user roles: Administrator, User).

Review what you made public

Lint rule SEC004 flags a project with guest access enabled, because any unconstrained read * granted to the anonymous role is readable by the whole internet (DIVD-2022-00019). Add an XPath constraint to each anonymous grant, or do not grant it.

See Also

Module Roles and User Roles

Mendix security uses a two-tier role system. Module roles define permissions within a single module. User roles aggregate module roles across the entire project.

Module Roles

A module role represents a set of permissions within a module. Entity access rules, microflow access, and page access are all granted to module roles.

CREATE MODULE ROLE

CREATE MODULE ROLE <Module>.<Role> [DESCRIPTION '<text>'];

Examples:

mdl 1;
CREATE MODULE ROLE Shop.Admin DESCRIPTION 'Full administrative access';
CREATE MODULE ROLE Shop.User DESCRIPTION 'Standard customer-facing role';
CREATE MODULE ROLE Shop.Viewer;

DROP MODULE ROLE

DROP MODULE ROLE Shop.Viewer;

Listing Module Roles

LIST MODULE ROLES;
LIST MODULE ROLES IN Shop;

User Roles

A user role is a project-level role assigned to end users at login. Each user role includes one or more module roles, granting the user the combined permissions of all included module roles.

CREATE USER ROLE

CREATE USER ROLE <Name> [( ModuleRoles: (<Module>.<Role> [, ...]), Description: '<text>',
    ManageAllRoles: true|false, ManageableRoles: (<UserRole> [, ...]),
    ManageUsersWithoutRoles: true|false, CheckSecurity: true|false )];

The MANAGE ALL ROLES option allows users with this role to assign any user role to other users (typically for administrators).

Examples:

mdl 1;
-- Administrator with management rights
CREATE USER ROLE AppAdmin ( ModuleRoles: (Shop.Admin, System.Administrator), ManageAllRoles: true );

-- Regular user
CREATE USER ROLE AppUser ( ModuleRoles: (Shop.User) );

-- Read-only viewer
CREATE USER ROLE AppViewer ( ModuleRoles: (Shop.Viewer) );

ALTER USER ROLE

Add or drop module roles from an existing user role:

mdl 1;
ALTER USER ROLE AppAdmin ADD MODULE ROLES (Reporting.Admin);
ALTER USER ROLE AppUser DROP MODULE ROLES (Shop.Viewer);

DROP USER ROLE

DROP USER ROLE AppViewer;

Listing User Roles

LIST USER ROLES;

Typical Setup

A common pattern is to create module roles first, then compose them into user roles:

mdl 1;
-- 1. Module roles
CREATE MODULE ROLE Shop.Admin DESCRIPTION 'Full shop access';
CREATE MODULE ROLE Shop.User DESCRIPTION 'Standard shop access';
CREATE MODULE ROLE Reporting.Viewer DESCRIPTION 'View reports';

-- 2. User roles
CREATE USER ROLE Administrator ( ModuleRoles: (Shop.Admin, Reporting.Viewer, System.Administrator), ManageAllRoles: true );
CREATE USER ROLE Employee ( ModuleRoles: (Shop.User, Reporting.Viewer) );
CREATE USER ROLE Guest ( ModuleRoles: (Shop.User) );

See Also

Entity Access

Entity access rules control which module roles can create, read, write, and delete objects of a given entity. Rules can restrict access to specific attributes and apply XPath constraints to limit which rows are visible.

GRANT on Entities

GRANT <rights> ON ENTITY <Module>.<Entity> TO <Module>.<Role> [, ...] [WHERE [<xpath>]];

The <rights> list is a comma-separated combination of:

RightSyntaxDescription
CreateCREATEAllow creating new objects
DeleteDELETEAllow deleting objects
Read allREAD *Read access to all attributes and associations
Read specificREAD (<attr>, ...)Read access to listed members only
Write allWRITE *Write access to all attributes and associations
Write specificWRITE (<attr>, ...)Write access to listed members only

Examples

Full Access

Grant all operations on all members:

GRANT CREATE, DELETE, READ *, WRITE * ON ENTITY Shop.Customer TO Shop.Admin;

Read-Only Access

GRANT READ * ON ENTITY Shop.Customer TO Shop.Viewer;

Selective Member Access

Restrict read and write to specific attributes:

GRANT READ (Name, Email, Status), WRITE (Email) ON ENTITY Shop.Customer TO Shop.User;

Members Added Later

A rule also carries a default for members added after it was written, and MDL derives that default from the grant rather than stating it: WRITE * gives ReadWrite, READ * gives ReadOnly, and a grant written purely as member lists leaves it at None.

So an attribute added later is granted None on a member-listed rule. The model is complete and correct — every rule gets an entry for the new member, and the build reports no errors — but the attribute renders blank for that role:

GRANT READ (Name, Email) ON ENTITY Shop.Customer TO Shop.User;
-- later:
--   alter entity Shop.Customer add attribute Phone: String;
-- Phone is granted None to Shop.User. Nothing is broken; it is simply not visible.

alter entity … add attribute reports this and prints the GRANT that fixes it:

Added attribute 'Phone' to entity Shop.Customer
Warning: Shop.Customer.Phone is not readable by Shop.User
  ...
    grant read (Phone) on entity Shop.Customer to Shop.User;

What decides this is the rule’s default, not how narrow its member list is. A rule granted READ *, WRITE (Email) is narrower than READ *, WRITE * and still sees new members, because READ * set its default to ReadOnly. If you want a role to pick up future members automatically, give its rule a READ * or WRITE * and narrow from there with REVOKE.

XPath Constraints

Limit which objects a role can see or modify using an XPath expression in the WHERE clause:

mdl 1;
-- Users can only access their own orders (Shop.Order stores its owner)
GRANT READ *, WRITE * ON ENTITY Shop.Order TO Shop.User
  WHERE [System.owner = '[%CurrentUser%]'];

-- Only open orders are editable
GRANT READ *, WRITE * ON ENTITY Shop.Order TO Shop.User
  WHERE [Status = 'Open'];

An access rule has no variables: $currentUser (or any $var) is CE0161 in a constraint, so write the user as the '[%CurrentUser%]' token, compared with System.owner on an entity that stores its owner or with an association to System.User. mxcli check reports a variable as MDL-XPATH04.

Note that single quotes inside XPath expressions must be doubled (''), since the entire expression is wrapped in single quotes.

Additive Behavior

GRANT is additive. If a role already has an access rule on the entity, the new rights are merged in without removing existing permissions:

mdl 1;
-- Initial grant
GRANT READ (Name, Email) ON ENTITY Shop.Customer TO Shop.User;

-- Add Notes access — Name and Email are preserved
GRANT READ (Notes) ON ENTITY Shop.Customer TO Shop.User;
-- Result: READ (Name, Email, Notes)

-- Upgrade Email to writable — existing reads preserved
GRANT WRITE (Email) ON ENTITY Shop.Customer TO Shop.User;
-- Result: READ (Name, Notes), WRITE (Email)

Merging only ever widens access — rights rank None < ReadOnly < ReadWrite, and a GRANT takes whichever is higher. To take access away, use REVOKE; that is what makes the two commands inverses rather than two spellings of “set”.

One Rule per Constraint

The constraint is part of a rule’s identity. Two GRANTs for the same role with the same WHERE update one rule; with different constraints they produce two, which is how row-level security is normally written:

mdl 1;
-- Two separate rules for one role
GRANT READ * ON ENTITY Shop.Order TO Shop.User WHERE [Status = 'Open'];
GRANT READ (Total) ON ENTITY Shop.Order TO Shop.User WHERE [System.owner = '[%CurrentUser%]'];

Mendix combines them at runtime: a role’s effective access is the union of every rule naming it. An unconstrained rule is distinct from a constrained one, so adding a WHERE to an existing GRANT creates a second rule rather than narrowing the first — and because a rule is matched on its constraint, re-running a script updates the same rules instead of accumulating new ones.

Multiple Roles on the Same Entity

Each GRANT creates a separate access rule. An entity can have rules for multiple roles:

mdl 1;
GRANT CREATE, DELETE, READ *, WRITE * ON ENTITY Shop.Order TO Shop.Admin;
GRANT READ *, WRITE * ON ENTITY Shop.Order TO Shop.User WHERE [Status = 'Open'];
GRANT READ * ON ENTITY Shop.Order TO Shop.Viewer;

REVOKE on Entities

Remove an entity access rule entirely or revoke specific rights:

-- Full revoke (removes entire rule)
REVOKE <Module>.<Role> ON <Module>.<Entity>;

-- Partial revoke (downgrades specific rights)
REVOKE <Module>.<Role> ON <Module>.<Entity> (<rights>);

Examples:

mdl 1;
-- Remove all access for Viewer
REVOKE ALL ON ENTITY Shop.Customer FROM Shop.Viewer;

-- Remove read access on a specific attribute
REVOKE READ (Notes) ON ENTITY Shop.Customer FROM Shop.User;

-- Downgrade write to read-only on Email
REVOKE WRITE (Email) ON ENTITY Shop.Customer FROM Shop.User;

A full REVOKE (without rights list) removes the entire access rule. A partial REVOKE downgrades specific rights: REVOKE READ (x) sets member x to no access, REVOKE WRITE (x) downgrades from ReadWrite to ReadOnly.

Viewing Entity Access

-- See which roles have access to an entity (the two spellings are synonyms)
LIST ACCESS ON ENTITY Shop.Customer;
LIST ACCESS ON Shop.Customer;

-- Full matrix across a module
DESCRIBE SECURITY MATRIX IN Shop;

See Also

Microflow, Page, and Nanoflow Access

Document-level access controls which module roles can execute microflows and nanoflows or view pages. GRANT adds roles and REVOKE removes them; neither replaces the list.

A new page, microflow or nanoflow has no allowed roles until you grant some, in every module: mxcli never creates a module role on its own. In a module with no module roles yet, create one first (create module role <Module>.User;) and map it into a user role. See GRANT.

Microflow Access

GRANT EXECUTE ON MICROFLOW

GRANT EXECUTE ON MICROFLOW <Module>.<Name> TO <Module>.<Role> [, ...];

Examples:

mdl 1;
-- Single role
GRANT EXECUTE ON MICROFLOW Shop.ACT_ProcessOrder TO Shop.Admin;

-- Multiple roles
GRANT EXECUTE ON MICROFLOW Shop.ACT_ViewOrders TO Shop.User, Shop.Admin;

REVOKE EXECUTE ON MICROFLOW

REVOKE EXECUTE ON MICROFLOW <Module>.<Name> FROM <Module>.<Role> [, ...];

Example:

REVOKE EXECUTE ON MICROFLOW Shop.ACT_ProcessOrder FROM Shop.User;

Page Access

GRANT VIEW ON PAGE

GRANT VIEW ON PAGE <Module>.<Name> TO <Module>.<Role> [, ...];

Examples:

mdl 1;
GRANT VIEW ON PAGE Shop.Order_Overview TO Shop.User, Shop.Admin;
GRANT VIEW ON PAGE Shop.Admin_Dashboard TO Shop.Admin;

REVOKE VIEW ON PAGE

REVOKE VIEW ON PAGE <Module>.<Name> FROM <Module>.<Role> [, ...];

Example:

REVOKE VIEW ON PAGE Shop.Admin_Dashboard FROM Shop.User;

Nanoflow Access

Nanoflow access uses the same syntax as microflow access:

mdl 1;
GRANT EXECUTE ON NANOFLOW Shop.NAV_Filter TO Shop.User, Shop.Admin;
REVOKE EXECUTE ON NANOFLOW Shop.NAV_Filter FROM Shop.User;

Viewing Document Access

LIST ACCESS ON MICROFLOW Shop.ACT_ProcessOrder;
LIST ACCESS ON PAGE Shop.Order_Overview;

Typical Pattern

After creating documents, grant access as part of the same script:

-- Create the microflow
CREATE MICROFLOW Shop.ACT_CreateOrder
BEGIN
  DECLARE $Order Shop.Order;
  $Order = CREATE Shop.Order (Status = 'Draft');
  COMMIT $Order;
  RETURN $Order;
END;

-- Grant access
GRANT EXECUTE ON MICROFLOW Shop.ACT_CreateOrder TO Shop.User, Shop.Admin;

-- Create the page
CREATE PAGE Shop.Order_Edit (
  Params: ( $Order: Shop.Order ),
  Title: 'Edit Order',
  Layout: Atlas_Core.PopupLayout
) { ... }

-- Grant access
GRANT VIEW ON PAGE Shop.Order_Edit TO Shop.User, Shop.Admin;

See Also

GRANT / REVOKE

The GRANT and REVOKE statements control all permissions in a Mendix project. They work on three targets: entities (CRUD access), microflows (execute access), and pages (view access).

Entity Access

GRANT

GRANT <rights> ON ENTITY <Module>.<Entity> TO <Module>.<Role> [, ...] [WHERE [<xpath>]];

Where <rights> is a comma-separated list of:

RightDescription
CREATEAllow creating new objects
DELETEAllow deleting objects
READ *Read all members
READ (<attr>, ...)Read specific members only
WRITE *Write all members
WRITE (<attr>, ...)Write specific members only

GRANT is additive: if the role already has an access rule on the entity, new rights are merged in. Existing permissions are never removed by a GRANT — only upgraded.

Examples:

mdl 1;
-- Full access
GRANT CREATE, DELETE, READ *, WRITE * ON ENTITY Shop.Customer TO Shop.Admin;

-- Read-only
GRANT READ * ON ENTITY Shop.Customer TO Shop.Viewer;

-- Selective members
GRANT READ (Name, Email), WRITE (Email) ON ENTITY Shop.Customer TO Shop.User;

-- With XPath constraint (doubled single quotes for string literals)
GRANT READ *, WRITE * ON ENTITY Shop.Order TO Shop.User
  WHERE [Status = 'Open'];

-- Additive: adds Notes to existing read access without removing Name, Email
GRANT READ (Notes) ON ENTITY Shop.Customer TO Shop.User;

REVOKE

Remove an entity access rule entirely, or revoke specific rights:

-- Full revoke (removes entire rule)
REVOKE <Module>.<Role> ON <Module>.<Entity>;

-- Partial revoke (downgrades specific rights)
REVOKE <Module>.<Role> ON <Module>.<Entity> (<rights>);

For partial revoke, REVOKE READ (x) sets member x access to None. REVOKE WRITE (x) downgrades member x from ReadWrite to ReadOnly. REVOKE CREATE / REVOKE DELETE removes the structural permission.

Examples:

mdl 1;
-- Remove all access
REVOKE ALL ON ENTITY Shop.Customer FROM Shop.Viewer;

-- Remove read access on a specific member
REVOKE READ (Notes) ON ENTITY Shop.Customer FROM Shop.User;

-- Downgrade write to read-only
REVOKE WRITE (Email) ON ENTITY Shop.Customer FROM Shop.User;

-- Remove delete permission only
REVOKE DELETE ON ENTITY Shop.Customer FROM Shop.User;

Microflow Access

GRANT EXECUTE ON MICROFLOW

GRANT EXECUTE ON MICROFLOW <Module>.<Name> TO <Module>.<Role> [, ...];

Example:

GRANT EXECUTE ON MICROFLOW Shop.ACT_ProcessOrder TO Shop.User, Shop.Admin;

REVOKE EXECUTE ON MICROFLOW

REVOKE EXECUTE ON MICROFLOW <Module>.<Name> FROM <Module>.<Role> [, ...];

Example:

REVOKE EXECUTE ON MICROFLOW Shop.ACT_ProcessOrder FROM Shop.User;

Page Access

GRANT VIEW ON PAGE

GRANT VIEW ON PAGE <Module>.<Name> TO <Module>.<Role> [, ...];

Example:

GRANT VIEW ON PAGE Shop.Order_Overview TO Shop.User, Shop.Admin;

REVOKE VIEW ON PAGE

REVOKE VIEW ON PAGE <Module>.<Name> FROM <Module>.<Role> [, ...];

Example:

REVOKE VIEW ON PAGE Shop.Admin_Dashboard FROM Shop.User;

Nanoflow Access

GRANT EXECUTE ON NANOFLOW <Module>.<Name> TO <Module>.<Role> [, ...];
REVOKE EXECUTE ON NANOFLOW <Module>.<Name> FROM <Module>.<Role> [, ...];

Workflow Access

Not supported. Mendix workflows do not have document-level AllowedModuleRoles (unlike microflows and pages). Workflow access is controlled through the microflow that triggers the workflow and UserTask targeting.

OData Service Access

GRANT ACCESS ON PUBLISHED ODATA SERVICE <Module>.<Name> TO <Module>.<Role> [, ...];
REVOKE ACCESS ON PUBLISHED ODATA SERVICE <Module>.<Name> FROM <Module>.<Role> [, ...];

Complete Example

A typical security setup script:

mdl 1;
-- Module roles
CREATE MODULE ROLE Shop.Admin DESCRIPTION 'Full access';
CREATE MODULE ROLE Shop.User DESCRIPTION 'Standard access';
CREATE MODULE ROLE Shop.Viewer DESCRIPTION 'Read-only access';

-- User roles
CREATE USER ROLE Administrator ( ModuleRoles: (Shop.Admin, System.Administrator), ManageAllRoles: true );
CREATE USER ROLE Employee ( ModuleRoles: (Shop.User, System.User) );
CREATE USER ROLE Guest ( ModuleRoles: (Shop.Viewer, System.User) );

-- Entity access
GRANT CREATE, DELETE, READ *, WRITE * ON ENTITY Shop.Customer TO Shop.Admin;
GRANT READ *, WRITE (Email, Phone) ON ENTITY Shop.Customer TO Shop.User;
GRANT READ * ON ENTITY Shop.Customer TO Shop.Viewer;

GRANT CREATE, DELETE, READ *, WRITE * ON ENTITY Shop.Order TO Shop.Admin;
GRANT CREATE, READ *, WRITE * ON ENTITY Shop.Order TO Shop.User
  WHERE [Status = 'Open'];
GRANT READ * ON ENTITY Shop.Order TO Shop.Viewer;

-- Microflow access
GRANT EXECUTE ON MICROFLOW Shop.ACT_ProcessOrder TO Shop.Admin;
GRANT EXECUTE ON MICROFLOW Shop.ACT_CreateOrder TO Shop.User, Shop.Admin;
GRANT EXECUTE ON MICROFLOW Shop.ACT_ViewOrders TO Shop.User, Shop.Admin, Shop.Viewer;

-- Page access
GRANT VIEW ON PAGE Shop.Order_Overview TO Shop.User, Shop.Admin, Shop.Viewer;
GRANT VIEW ON PAGE Shop.Order_Edit TO Shop.User, Shop.Admin;
GRANT VIEW ON PAGE Shop.Admin_Dashboard TO Shop.Admin;

-- Demo users
CREATE DEMO USER 'demo_admin' ( Password: 'Admin123!', UserRoles: (Administrator) );
CREATE DEMO USER 'demo_user' ( Password: 'User123!', UserRoles: (Employee) );

-- Enable demo users
ALTER APP SECURITY ( EnableDemoUsers: TRUE );
ALTER APP SECURITY ( SecurityLevel: PROTOTYPE );

See Also

Demo Users

Demo users are test accounts created for development and testing. They appear on the login screen when demo users are enabled, allowing quick access without manual user setup.

CREATE DEMO USER

CREATE DEMO USER '<username>' (
  Password: '<password>',
  [Entity: <Module>.<Entity>,]
  UserRoles: (<UserRole> [, ...])
);
ParameterDescription
<username>Login name for the demo user
PasswordPassword (visible in development only)
EntityOptional. The entity that generalizes System.User (e.g., Administration.Account). If omitted, the system auto-detects the unique System.User subtype.
UserRolesThe project-level user roles to assign

The keys are Studio Pro’s property names. The clause form PASSWORD '<password>' [ENTITY <Module>.<Entity>] (<UserRole>, …) is the deprecated alias of the list (MDL-DEPR137); mxcli fmt --upgrade rewrites it.

Examples

mdl 1;
-- Basic demo user
CREATE DEMO USER 'demo_admin' ( Password: 'Admin123!', UserRoles: (Administrator) );

-- With explicit entity
CREATE DEMO USER 'demo_admin' ( Password: 'Admin123!', Entity: Administration.Account, UserRoles: (Administrator) );

-- Multiple roles
CREATE DEMO USER 'demo_manager' ( Password: 'Manager1!', UserRoles: (Manager, Reporting) );

-- Standard user
CREATE DEMO USER 'demo_user' ( Password: 'User1234!', UserRoles: (Employee) );

DROP DEMO USER

DROP DEMO USER '<username>';

Example:

DROP DEMO USER 'demo_admin';

Enabling Demo Users

Demo users only appear on the login screen when enabled in project security:

ALTER APP SECURITY ( EnableDemoUsers: TRUE );

To hide them:

ALTER APP SECURITY ( EnableDemoUsers: FALSE );

Listing Demo Users

LIST DEMO USERS;

Typical Setup

mdl 1;
-- Enable demo users and set prototype security
ALTER APP SECURITY ( SecurityLevel: PROTOTYPE );
ALTER APP SECURITY ( EnableDemoUsers: TRUE );

-- Create demo accounts for each role
CREATE DEMO USER 'demo_admin' ( Password: 'Admin123!', Entity: Administration.Account, UserRoles: (Administrator) );
CREATE DEMO USER 'demo_user' ( Password: 'User1234!', Entity: Administration.Account, UserRoles: (Employee) );
CREATE DEMO USER 'demo_guest' ( Password: 'Guest123!', Entity: Administration.Account, UserRoles: (Guest) );

See Also

Navigation and Settings

Navigation defines how users move through a Mendix application. Each device type has its own navigation profile with a home page, optional role-specific home pages, a login page, and a menu structure. Project settings control runtime behavior, configurations, languages, and workflows.

A Mendix application has one or more navigation profiles, each targeting a device type. MDL supports inspecting and replacing entire profiles.

Inspecting Navigation

-- Summary of all profiles
LIST NAVIGATION;

-- Menu tree for a specific profile
LIST NAVIGATION MENU Responsive;

-- Home page assignments across all profiles
LIST NAVIGATION HOMES;

-- Full MDL output (round-trippable)
DESCRIBE NAVIGATION;
DESCRIBE NAVIGATION Responsive;
ProfileDescription
ResponsiveWeb browser (desktop, laptop)
TabletTablet browser
PhonePhone browser
NativePhoneNative mobile (React Native)

See Navigation Profiles for full syntax.

Settings Overview

Project settings control runtime configuration, database connections, languages, and workflow behavior.

-- Overview of all settings
LIST SETTINGS;

-- Full MDL output
DESCRIBE SETTINGS;

See Project Settings for the ALTER SETTINGS syntax.

See Also

Navigation Profiles

A navigation profile defines the navigation structure for a specific device type. Each profile has a default home page, optional role-specific home pages, an optional login page, and a menu.

Profile Types

ProfileMDL NameDescription
Responsive webResponsiveDefault browser navigation
Tablet webTabletTablet-optimized browser navigation
Phone webPhonePhone-optimized browser navigation
Responsive web offlineResponsiveOfflineOffline-capable (PWA)
Tablet web offlineTabletOfflineOffline-capable (PWA)
Phone web offlinePhoneOfflineOffline-capable (PWA)
Native mobileNativePhoneReact Native mobile navigation

The web kinds are a closed set and the profile is created if the project does not have it yet. The offline kinds are the online names plus Offline.

CREATE OR MODIFY NAVIGATION

Sets the clauses it states on a navigation profile. Every clause is optional, and an omitted clause keeps the stored value: a statement with only a menu keeps the home page, role home pages, login page and not-found page. The { } block replaces the whole menu, and HOME … FOR <UserRole> replaces the home page of that role only.

CREATE OR MODIFY NAVIGATION <Profile>
  HOME PAGE <Module>.<Page>
  [HOME PAGE <Module>.<Page> FOR <UserRole>]
  [LOGIN PAGE <Module>.<Page>]
  [NOT FOUND PAGE <Module>.<Page>]
  [{
    <menu-items>
  }]

The menu items are the profile’s children, in { } after its clauses and with no ; between them. The old MENU ( … ) block still parses and warns (MDL-DEPR121).

Full Example

CREATE OR MODIFY NAVIGATION Responsive
  HOME PAGE MyModule.Home_Web
  HOME PAGE MyModule.AdminHome FOR Administrator
  LOGIN PAGE Administration.Login
  NOT FOUND PAGE MyModule.Custom404
  {
    MENU ITEM 'Home' ( OnClick: SHOW PAGE MyModule.Home_Web )
    MENU ITEM 'Orders' ( OnClick: SHOW PAGE Shop.Order_Overview )
    MENU 'Administration' {
      MENU ITEM 'Users' ( OnClick: SHOW PAGE Administration.Account_Overview )
      MENU ITEM 'Settings' ( OnClick: SHOW PAGE MyModule.Settings )
    }
  };

Minimal Example

A profile only requires a home page:

CREATE OR MODIFY NAVIGATION Phone
  HOME PAGE MyModule.Home_Phone;

Offline Synchronization

An offline profile downloads nothing until its entities have a sync mode. Without a sync block the app builds, routes and installs as a PWA — and shows an empty screen. This is the most common way an offline profile looks broken while every check passes.

create or modify navigation PhoneOffline
  home page MyModule.Mobile_Dashboard
  sync (
    sync MyModule.Setting online;
    sync MyModule.Order all;
    sync MyModule.Trip where [Distance > 0];
    sync MyModule.Audit never;
    sync MyModule.Lookup none;
    sync MyModule.Draft none preserve data;
  );
ModeMeaning
onlinefetched from the server, never held on the device
allevery object downloaded
where [xpath]only the objects the XPath selects
nevernot synchronized
nonenot downloaded; anything already on the device is dropped
none preserve datanot downloaded; what is on the device stays

The words are the values Mendix stores, not Studio Pro’s captions — its “All Objects” is all and its “By XPath” is where. Copying a caption gives a parse error rather than a broken document.

See CREATE NAVIGATION for the full reference.

DESCRIBE NAVIGATION

View the current navigation profile in MDL syntax:

-- All profiles
DESCRIBE NAVIGATION;

-- Single profile
DESCRIBE NAVIGATION Responsive;

The output is round-trippable – you can copy it, modify it, and execute it as a CREATE OR MODIFY NAVIGATION statement.

ALTER NAVIGATION

To change one menu item, or to clear a page clause, edit the stored profile instead of restating it. The items are addressed by the captions DESCRIBE prints, as a path ('Admin' > 'Users'); LIST NAVIGATION MENU prints each item’s address.

ALTER NAVIGATION Responsive {
  INSERT AFTER 'Home' { MENU ITEM 'Orders' ( OnClick: SHOW PAGE Shop.Order_Overview ) }
  SET ( Caption: 'User accounts' ) ON 'Admin' > 'Users';
  MOVE 'Log out' AFTER 'Help';
  DROP 'Old import';
  DROP NOT FOUND PAGE;
};

Everything the statement does not name stays exactly as stored. See ALTER NAVIGATION for every operation; ALTER MENU does the same for a menu document.

See Also

Home Pages and Menus

Each navigation profile has a default home page, optional role-specific home pages, and a menu tree. These determine what users see when they first open the application and how they navigate between pages.

Home Pages

Default Home Page

Every profile requires a default home page. This is shown to users whose role has no specific home page assignment:

CREATE OR MODIFY NAVIGATION Responsive
  HOME PAGE MyModule.Home_Web;

Role-Specific Home Pages

Use HOME PAGE ... FOR to direct users to different pages based on their user role:

CREATE OR MODIFY NAVIGATION Responsive
  HOME PAGE MyModule.Home_Web
  HOME PAGE MyModule.AdminDashboard FOR Administrator
  HOME PAGE MyModule.ManagerDashboard FOR Manager;

Write the role as a bare name. FOR takes a user role, which is project-level and has no module part — FOR Administrator, not FOR MyModule.Administrator. A module role is a different thing that happens to share the name: a blank app has a user role Administrator and module roles called Administrator in three modules, so the wrong one looks right. A module-qualified name here produces a project Mendix cannot load (StorageLoadException: … is not a valid UserRoleIdentifier), which is worse than a build error because it happens before checking runs. mxcli check --references refuses it.

When a user logs in, the runtime checks their roles and redirects to the most specific matching home page. If no role-specific page matches, the default home page is used.

Login Page

The login page is shown to unauthenticated users:

CREATE OR MODIFY NAVIGATION Responsive
  HOME PAGE MyModule.Home_Web
  LOGIN PAGE Administration.Login;

Not Found Page

An optional custom 404 page:

CREATE OR MODIFY NAVIGATION Responsive
  HOME PAGE MyModule.Home_Web
  NOT FOUND PAGE MyModule.Custom404;

The { } block after the profile’s clauses defines the navigation menu as a tree of items and submenus.

A menu item links a label to a page, a microflow, or sign-out, in the words a page action uses:

MENU ITEM '<label>' ( OnClick: SHOW PAGE <Module>.<Page> )
MENU ITEM '<label>' ( OnClick: CALL MICROFLOW <Module>.<Microflow>, Icon: <Module>.<IconCollection>.<Icon> )
MENU ITEM '<label>' ( OnClick: SIGN OUT )

Nest items inside a MENU '<label>' { ... } block:

MENU '<label>' [( Icon: <icon> )] {
  <menu-items>
}

Complete Example

CREATE OR MODIFY NAVIGATION Responsive
  HOME PAGE Shop.Home
  LOGIN PAGE Administration.Login
  {
    MENU ITEM 'Home' ( OnClick: SHOW PAGE Shop.Home )
    MENU ITEM 'Products' ( OnClick: SHOW PAGE Shop.Product_Overview )
    MENU ITEM 'Orders' ( OnClick: SHOW PAGE Shop.Order_Overview )
    MENU 'Administration' {
      MENU ITEM 'Users' ( OnClick: SHOW PAGE Administration.Account_Overview )
      MENU ITEM 'Roles' ( OnClick: SHOW PAGE Administration.Role_Overview )
      MENU 'System' {
        MENU ITEM 'Logs' ( OnClick: SHOW PAGE Administration.Log_Overview )
        MENU ITEM 'Settings' ( OnClick: SHOW PAGE Shop.Settings )
      }
    }
  };

Inspecting Menus

-- View menu tree
LIST NAVIGATION MENU;
LIST NAVIGATION MENU Responsive;

-- View home page assignments
LIST NAVIGATION HOMES;

See Also

Project Settings

Project settings control application runtime behavior, server configurations, language settings, and workflow configuration. MDL provides commands to inspect and modify all settings categories.

Inspecting Settings

-- Overview of all settings
LIST SETTINGS;

-- Full MDL output (round-trippable)
DESCRIBE SETTINGS;

ALTER SETTINGS

Settings are organized into categories. Each ALTER SETTINGS command targets one category.

Model Settings

Runtime-level settings such as the after-startup microflow, hashing algorithm, and Java version:

ALTER SETTINGS RUNTIME ( <Key>: <Value>, ... );

Examples:

mdl 1;
ALTER SETTINGS RUNTIME ( AfterStartupMicroflow: 'MyModule.ACT_Startup' );
ALTER SETTINGS RUNTIME ( HashAlgorithm: 'BCrypt' );
ALTER SETTINGS RUNTIME ( JavaVersion: '17' );

Mendix renamed the Java version property between versions — up to 11.6 it is stored as JavaVersion = Java21, from 11.12 as JavaMajorVersion = 21. Write either spelling ('17' or 'Java17'): mxcli stores the value in whichever dialect the project already uses.

Configuration Settings

Server configuration settings like database type, URL, and HTTP port. Each configuration is identified by name (commonly 'default'):

ALTER SETTINGS CONFIGURATION '<Name>' ( <Key>: <Value>, ... );

Examples:

mdl 1;
ALTER SETTINGS CONFIGURATION 'default' ( DatabaseType: 'POSTGRESQL' );
ALTER SETTINGS CONFIGURATION 'default' ( DatabaseUrl: 'jdbc:postgresql://localhost:5432/myapp' );
ALTER SETTINGS CONFIGURATION 'default' ( HttpPortNumber: '8080' );

Constant Overrides

Override a constant value within a specific configuration:

ALTER SETTINGS CONSTANT @<Module.Constant> VALUE '<value>' IN CONFIGURATION '<cfg>';

Example:

ALTER SETTINGS CONSTANT @MyModule.ApiBaseUrl VALUE 'https://staging.example.com' IN CONFIGURATION 'default';

Language Settings

A project’s enabled languages are the only ones a build emits translations for. A translation written for any other language is stored in the model, passes mx check, and is silently discarded at build time — so enabling the language is the step that makes translating an app do anything.

ALTER SETTINGS LANGUAGE ( <Key>: <Value>, ... );
ALTER SETTINGS LANGUAGE ADD [OR MODIFY] '<code>' [( <option>: <value>, ... )];
ALTER SETTINGS LANGUAGE MODIFY '<code>' ( <option>: <value>, ... );
ALTER SETTINGS LANGUAGE DROP '<code>';
mdl 1;
-- enable a language; the defaults match Studio Pro's Add Language dialog
ALTER SETTINGS LANGUAGE ADD 'de_DE';

-- with options
ALTER SETTINGS LANGUAGE ADD 'ar_SD' (CheckCompleteness: true, CustomDateFormat: 'yyyy-MM-dd');

-- change one that is already enabled (only the options named are touched)
ALTER SETTINGS LANGUAGE MODIFY 'de_DE' (CheckCompleteness: true);

-- make it the default (it must already be enabled)
ALTER SETTINGS LANGUAGE ( DefaultLanguageCode: 'de_DE' );

-- disable it
ALTER SETTINGS LANGUAGE DROP 'de_DE';

A language is identified by its code alone — Studio Pro’s “Arabic, Sudan” is derived from ar_SD for display and is not stored in the model.

optionmeaning
CheckCompletenessreport errors and warnings for texts that have no translation in this language, instead of letting them fall back to the default silently. The default language is always checked whatever this says.
CustomDateFormat, CustomTimeFormat, CustomDateTimeFormatoverride Mendix’s date/time formatting for this language. Empty means default formatting.

ADD OR MODIFY is the upsert — it enables a language that is not there and changes one that is. It is what DESCRIBE SETTINGS emits, so a described project replays onto one that already has some of its languages.

Two things to know about removal:

  • The default language cannot be removed — every missing translation falls back on it. Change DefaultLanguageCode first.
  • Removing a language does not delete its translations. They stay in the model and stop being built; the run reports how many source strings are affected. To remove them, use CREATE OR REPLACE TRANSLATIONS FOR '<code>' ( );.

Set the default language before authoring content

The default language is not only a fallback — it is the language a new caption is written in. Caption: 'Opslaan' has to be stored under some language code (Mendix has no language-neutral text), and mxcli uses the project’s DefaultLanguageCode.

So the order of these two statements changes the result:

-- right: the page's texts are stored as nl_NL
ALTER SETTINGS LANGUAGE ADD 'nl_NL';
ALTER SETTINGS LANGUAGE ( DefaultLanguageCode: 'nl_NL' );
CREATE PAGE MyModule.Opslaan ( Title: 'Opslaanpagina', ... ) { ... }

-- wrong: the page is authored while en_US is still the default, so its texts
-- are stored as en_US and stay there
CREATE PAGE MyModule.Opslaan ( Title: 'Opslaanpagina', ... ) { ... }
ALTER SETTINGS LANGUAGE ( DefaultLanguageCode: 'nl_NL' );

Changing DefaultLanguageCode does not move text that already exists — it would be rewriting content you authored. Nothing reports the mismatch either: mx check gives 0 errors both ways, and the symptom only appears in Studio Pro, as the empty-caption placeholder plus a “no translation for this language” warning.

If you get the order wrong, re-run the CREATE statements. With the new default in place the texts are stored under it; the old language’s copy stays alongside as a harmless extra translation.

-- recovery: same script, run again after the default is correct
CREATE OR MODIFY PAGE MyModule.Opslaan ( Title: 'Opslaanpagina', ... ) { ... }

CREATE TRANSLATIONS FOR '<the default>' is not the way out — it is refused, because the default is the source language every other translation is keyed on.

LIST LANGUAGES lists languages that have translations, which is not the same list — a stock app reports eight while one is enabled. For the enabled list use DESCRIBE SETTINGS.

Workflow Settings

Configure workflow behavior such as the user entity and task parallelism:

ALTER SETTINGS WORKFLOWS ( <Key>: <Value>, ... );

Examples:

mdl 1;
ALTER SETTINGS WORKFLOWS ( UserEntity: 'Administration.Account' );
ALTER SETTINGS WORKFLOWS ( DefaultTaskParallelism: '5' );

See Also

Translations

Every user-visible string in a Mendix app — page titles, widget captions, button labels, validation messages, enum captions, menu items — is a text with one translation per language. MDL works on them in bulk: one file per language.

The loop

# 1. export — an untranslated string comes back with an EMPTY target
mxcli -p app.mpr -c "describe translations for de_DE" > de_DE.mdl

# 2. fill in the right-hand sides, by hand or by handing the file to an LLM

# 3. write them back
mxcli exec de_DE.mdl -p app.mpr

DESCRIBE emits the CREATE form, so the export format and the import format are the same file and the empty targets are the prompt. A whole app is typically a few hundred distinct source strings, so one file per language stays practical.

Statements

DESCRIBE TRANSLATIONS [IN <Module> | WITHOUT MARKETPLACE] FOR <lang>;

CREATE            TRANSLATIONS [IN <Module> | WITHOUT MARKETPLACE] FOR <lang> ( '<source>' AS '<target>', ... );
CREATE OR MODIFY  TRANSLATIONS [IN <Module> | WITHOUT MARKETPLACE] FOR <lang> ( ... );
CREATE OR REPLACE TRANSLATIONS [IN <Module> | WITHOUT MARKETPLACE] FOR <lang> ( ... );

Entries use AS, not a colon: a translation maps a user-provided name to another name.

VerbMeaning
CREATEthe “add a language” form — refuses if that language already has translations in scope
CREATE OR MODIFYmerge; a source string the file does not name keeps what it has
CREATE OR REPLACEthe file is authoritative — a translation whose source it does not name is removed, and the run says which

IN <Module> scopes both directions, and under OR REPLACE it bounds the deletion — without it, per-module files would wipe each other on every run.

Marketplace modules

Without IN, a run reaches the whole project, Marketplace modules included — and with them the Atlas page templates and building blocks. On a stock app, 'Cancel' AS 'Annuleren' set 50 translations in 38 documents, 35 of them in Marketplace modules. A module update replaces a Marketplace module’s contents, so those translations are lost at the next update and show up as unexpected diffs until then. The run warns, with a count per module:

Warning: 47 of these translation change(s) landed in 35 document(s) of Marketplace
modules (Atlas_Web_Content 13, FeedbackModule 2, WorkflowCommons 20; 14 of the documents are page templates or building blocks).

WITHOUT MARKETPLACE keeps the run in your own modules, and names the file’s entries that also occur in the modules it skipped. Under OR REPLACE it also keeps the deletion out of them. DESCRIBE accepts it too, and emits it, so the described file round-trips:

mdl 1;
describe translations without marketplace for nl_NL;
create or modify translations without marketplace for nl_NL ( 'Cancel' as 'Annuleren' );

Naming a Marketplace module with IN <Module> is taken as meaning it, and does not warn. The default stays the whole project: what an existing script writes does not change underneath it.

A translation is only built if its language is enabled

This is the one thing worth knowing before translating anything.

A translation written for a language the project has not enabled is stored in the model, passes mx check, is kept by Studio Pro — and is discarded at build time. Measured with mxbuild --target=deploy: the string appears nowhere under deployment/, and no translations_<code>.properties is produced at all.

So it is possible to translate four hundred strings into German, see every run report success, and get no German in the app. CREATE TRANSLATIONS warns when the language is not enabled. Enable it first — see Project Settings.

A stock app invites the mistake: it enables one language while its marketplace modules ship translations in nine, so “other languages already have translations here” is true and misleading.

LIST LANGUAGES lists languages that have translations, which is a different list — a stock app reports nine while one is enabled. The enabled list is in DESCRIBE SETTINGS.

Drift: a source string that was edited

The dictionary is keyed on the source string, so Save is translated once for every place it occurs. The flip side is that editing a source after the file was written stops it matching.

A key that matches nothing is reported, not skipped — and where the translation identifies the moved source unambiguously, the run names the fix:

Warning: 1 source string(s) in the file matched nothing in the project.

  "Thingz" as "Grejer"
      No text has "Thingz" as its source. A text now reads "Things" and carries
      the sv_SE "Grejer" — the source was probably edited. Change the file to:
        "Things" as "Grejer"

Notes

  • The source language is the project’s default: the left-hand column is what DESCRIBE shows for it. Translating into the source language is refused — it would overwrite the strings everything else is keyed on.
  • A rewrite keeps other languages. Re-executing a page or microflow does not drop the translations MDL cannot express, so you need not re-import a language after editing a document.
  • To remove a language’s translations, make an empty file authoritative: CREATE OR REPLACE TRANSLATIONS FOR de_DE ( );

Workflows

Workflows model long-running, multi-step business processes such as approval chains, onboarding sequences, and review cycles. Unlike microflows, which execute synchronously in a single transaction, workflows persist their state and can pause indefinitely – waiting for user input, timers, or external notifications.

When to Use Workflows

Workflows are appropriate when a process:

  • Involves human tasks that require user action at specific steps
  • Spans hours, days, or weeks rather than completing instantly
  • Has branching logic based on user decisions (approve / reject / escalate)
  • Needs a visual overview of progress for administrators

For immediate, transactional logic, use microflows instead. See Workflow vs Microflow for a detailed comparison.

Inspecting Workflows

-- List all workflows
LIST WORKFLOWS;
LIST WORKFLOWS IN MyModule;

-- View full definition
DESCRIBE WORKFLOW MyModule.ApprovalFlow;

Quick Example

CREATE WORKFLOW Approval.RequestApproval
  PARAMETER $Context: Approval.Request
  OVERVIEW PAGE Approval.WorkflowOverview
BEGIN
  USER TASK ReviewTask 'Review the request'
    PAGE Approval.ReviewPage
    OUTCOMES 'Approve' {
      CALL MICROFLOW Approval.ACT_Approve;
    } 'Reject' {
      CALL MICROFLOW Approval.ACT_Reject;
    };
END WORKFLOW;

See Also

Workflow Structure

A workflow definition consists of a context parameter, an optional overview page, and a sequence of activities. Activities execute in order unless control flow elements (decisions, parallel splits, jumps) alter the sequence.

CREATE WORKFLOW

CREATE [OR MODIFY] WORKFLOW <Module>.<Name>
  PARAMETER $<Name>: <Module>.<Entity>
  [OVERVIEW PAGE <Module>.<Page>]
  [ON WORKFLOW EVENTS (<type>, ...) MICROFLOW <Module>.<Microflow> [AS '<description>']] ...
  [ON ANY WORKFLOW EVENT MICROFLOW <Module>.<Microflow> [AS '<description>']] ...
BEGIN
  <activities>
END WORKFLOW;
ElementDescription
PARAMETERThe workflow context object, passed when the workflow is started
OVERVIEW PAGEOptional page shown in the workflow admin dashboard
ON WORKFLOW EVENTSA microflow run for each listed workflow event; repeatable. See Workflow Event Handlers
ActivitiesA sequence of user tasks, microflow calls, decisions, etc.

Full Example

CREATE WORKFLOW HR.OnboardEmployee
  PARAMETER $Context: HR.OnboardingRequest
  OVERVIEW PAGE HR.OnboardingOverview
BEGIN
  -- Parallel: IT setup and HR paperwork happen simultaneously
  PARALLEL SPLIT
    PATH 1 {
      USER TASK SetupLaptop 'Set up laptop and accounts'
        PAGE HR.IT_SetupPage
        OUTCOMES 'Done' { };
    }
    PATH 2 {
      USER TASK SignDocuments 'Sign employment documents'
        PAGE HR.DocumentSignPage
        OUTCOMES 'Signed' { };
    };

  -- After both paths complete, manager reviews
  DECISION $WorkflowContext/NeedsManagerApproval CAPTION 'Manager approval required?'
    OUTCOMES TRUE -> {
      USER TASK ManagerReview 'Review onboarding'
        PAGE HR.ManagerReviewPage
        OUTCOMES 'Approve' { } 'Reject' {
          CALL MICROFLOW HR.ACT_RejectOnboarding;
          END WORKFLOW;
        };
    } FALSE -> { };

  CALL MICROFLOW HR.ACT_CompleteOnboarding;
END WORKFLOW;

Workflow Event Handlers

A handler runs a microflow whenever one of the listed events happens — a task starting or ending, the workflow completing, failing or being aborted. Use one to audit a process or react to it without adding activities to the flow.

mdl 1;
CREATE MICROFLOW HR.ACT_AuditTask (
  $WorkflowEvent: System.WorkflowEvent,
  $WorkflowRecord: System.WorkflowRecord,
  $WorkflowActivityRecord: System.WorkflowActivityRecord
) BEGIN END;

CREATE WORKFLOW HR.LeaveApproval
  PARAMETER $Request: HR.LeaveRequest
  ON WORKFLOW EVENTS (UserTaskStarted, UserTaskEnded) MICROFLOW HR.ACT_AuditTask AS 'Task audit'
  ON ANY WORKFLOW EVENT MICROFLOW HR.ACT_LogEvent AS 'OnAnyEvent'
BEGIN
  USER TASK Review 'Review the request'
    PAGE HR.ReviewPage
    OUTCOMES 'Approve' { } 'Reject' { };
END WORKFLOW;
  • The handler microflow takes exactly System.WorkflowEvent, System.WorkflowRecord and System.WorkflowActivityRecord, in any order. Anything else fails the build with CE6691.
  • The build does not check event type names: a handler listing a name that is not an event type builds cleanly and never runs for it. mxcli check refuses an unknown name (MDL-WF12) and, with a project, a type that project’s Mendix version does not have. mxcli syntax workflow.event-handlers lists the types.
  • ON ANY WORKFLOW EVENT stores every event type the project’s Mendix version has — Studio Pro stores the list, not a flag — so it needs Mendix 11.6 or later. On older projects, name the types.
  • AS '...' is the handler’s description, which is how Studio Pro lists it.

DROP WORKFLOW

DROP WORKFLOW HR.OnboardEmployee;

Workflow Access

Not supported. Mendix workflows do not have document-level AllowedModuleRoles (unlike microflows and pages). Workflow access is controlled through the microflow that triggers the workflow and UserTask targeting.

See Also

Activity Types

Workflow activities are the building blocks of a workflow definition. Each activity type serves a different purpose, from waiting for user input to calling microflows or branching execution.

User Task

A user task pauses the workflow until a user completes it. Each outcome resumes the workflow down a different path.

USER TASK <name> '<caption>'
  [PAGE <Module>.<Page>]
  [TARGETING MICROFLOW <Module>.<Microflow>]
  [AUTO ASSIGN SINGLE USER]
  [ON CREATED MICROFLOW <Module>.<Microflow>]
  OUTCOMES '<outcome>' { <activities> } ['<outcome>' { <activities> }] ...;
ElementDescription
<name>Internal activity name
<caption>Display label shown to users
PAGEThe page opened when the user acts on the task
TARGETING MICROFLOWMicroflow that determines which users see the task
AUTO ASSIGN SINGLE USERStudio Pro’s “Auto-assign when targeting results in one user”: when the targeting yields exactly one user, the task is assigned to them. Off when omitted, as Studio Pro creates a task. Without it, that user cannot complete the task from a microflow until it is assigned to them. Valid on multi-user tasks too
ON CREATED MICROFLOWMicroflow run when the task is created — for example to assign it. It takes exactly System.WorkflowUserTask and the workflow’s context entity, in either order (else CE6683), and returns nothing (else CE5012)
OUTCOMESNamed outcomes, each with a block of follow-up activities

Example:

USER TASK ReviewTask 'Review the request'
  PAGE Approval.ReviewPage
  TARGETING MICROFLOW Approval.ACT_GetReviewers
  ON CREATED MICROFLOW Approval.ACT_AssignReviewer
  OUTCOMES 'Approve' {
    CALL MICROFLOW Approval.ACT_Approve;
  } 'Reject' {
    CALL MICROFLOW Approval.ACT_Reject;
    END WORKFLOW;
  };

Multi-User Task

A multi-user task gives the same task to several users and combines their outcomes into one. Three clauses, before OUTCOMES, say how:

MULTI USER TASK <name> '<caption>'
  PAGE <Module>.<Page>
  [PARTICIPANTS ALL | <n> | <n> PERCENT]
  [DECIDE BY <rule>]
  [AWAIT ALL USERS]
  OUTCOMES '<outcome>' { <activities> } ...;
RuleCompletes whenNeeds
CONSENSUSeveryone chose the same outcomeFALLBACK '<outcome>'
MAJORITY MORE THAN HALFmore than half chose one outcomeFALLBACK '<outcome>'
MAJORITY MOST CHOSENone outcome was chosen mostFALLBACK '<outcome>'
THRESHOLD <n> PERCENT / <n> VOTESan outcome reaches the thresholdFALLBACK '<outcome>'
VETO '<outcome>'anyone chooses the veto outcome—
MICROFLOW <Module>.<Name>the microflow returns the outcome (String)—

Example:

MULTI USER TASK Vote 'Vote on the request'
  PAGE HR.VotePage
  PARTICIPANTS 80 PERCENT
  DECIDE BY THRESHOLD 60 PERCENT FALLBACK 'Reject'
  AWAIT ALL USERS
  OUTCOMES 'Approve' { } 'Reject' { };

Omitted, the task needs all participants, decides by consensus falling back to its first outcome, and does not wait for everyone. The fallback is required for consensus, majority and threshold, and the build does not check threshold or participant numbers against each other — THRESHOLD 5 VOTES with three users builds and never completes.

Call Microflow

Execute a microflow as part of the workflow. Optionally specify a caption and outcomes:

CALL MICROFLOW <Module>.<Name> [(<Param> = <expression>, ...)] [CAPTION '<text>']
  [OUTCOMES '<outcome>' { <activities> } ...];

Example:

CALL MICROFLOW HR.ACT_SendNotification CAPTION 'Notify the applicant';
CALL MICROFLOW HR.ACT_Escalate(Request = $WorkflowContext) CAPTION 'Escalate';

Arguments are bound like every other call in MDL: Param = expression right after the callee, the expression written bare. The older spelling after the caption, WITH (Param = '<expression>') with the expression inside a string, still parses with the same meaning but is deprecated (MDL-DEPR008); mxcli fmt --upgrade rewrites it.

CAPTION '…' sets the caption Studio Pro shows on the activity, on every workflow activity. It used to be spelt COMMENT '…', which still parses as a deprecated alias (MDL-DEPR104) — it was never a comment.

AI Agent Task

A step that runs an AI agent (Mendix 11.9 or later). It is written like CALL MICROFLOW with AGENT added, and takes the same name, caption, parameter mappings, outcomes and boundary events. The microflow is where the agent is invoked — build agents with the Studio Pro Agent Editor, or CREATE AGENT.

CALL AGENT MICROFLOW <Module>.<Name> [(<Param> = <expression>, ...)] [AS <name>] [CAPTION '<text>']
  [OUTCOMES <true|false|'Module.Enum.Value'|''> -> { <activities> } ...];

Example — branch on the agent’s answer:

CALL AGENT MICROFLOW HR.ACT_ClassifyRequest(Request = $WorkflowContext) AS aiAgentTask1
  CAPTION 'Classify the request'
  OUTCOMES true -> {
    USER TASK Expedite 'Expedite the request' PAGE HR.TaskPage OUTCOMES 'Done' { };
  } false -> { };

The microflow must take at least one parameter — usually the workflow’s context object — or the build fails with CE1590. Return Boolean or an enumeration to branch with OUTCOMES; return nothing for a single path.

Call Workflow

Start a sub-workflow:

CALL WORKFLOW <Module>.<Name> [(<Param> = <expression>, ...)] [AS <name>] [CAPTION '<text>']
  [BOUNDARY EVENT ...];

Example:

CALL WORKFLOW HR.BackgroundCheck CAPTION 'Run background check sub-process';

A call workflow takes the same boundary events as a user task or call microflow, so a parent can put a deadline on a child without moving the timer into it:

CALL WORKFLOW HR.BackgroundCheck AS callCheck
  BOUNDARY EVENT INTERRUPTING TIMER addDays([%CurrentDateTime%], 5) { END WORKFLOW; };

Decision

Branch the workflow based on a condition. The condition is a bare expression, Boolean or enumeration; each outcome contains a block of activities:

DECISION [<name>] <expression> [COMMENT '<caption>']
  OUTCOMES TRUE -> { <activities> } FALSE -> { <activities> };

Example:

DECISION $WorkflowContext/Total > 1000 CAPTION 'Order value over $1000?'
  OUTCOMES TRUE -> {
    USER TASK ManagerApproval 'Manager must approve'
      PAGE Shop.ApprovalPage
      OUTCOMES 'Approved' { } 'Rejected' { END WORKFLOW; };
  } FALSE -> { };

An enumeration decision has one outcome per qualified enumeration value, plus '' for “none of the above”. The expression used to be written in a string (DECISION '$WorkflowContext/Total > 1000'); that form still parses and warns MDL-DEPR080, and mxcli fmt --upgrade rewrites it.

Parallel Split

Execute multiple paths concurrently. The workflow continues after all paths complete:

PARALLEL SPLIT
  PATH 1 { <activities> }
  PATH 2 { <activities> }
  [PATH 3 { <activities> }] ...;

Example:

PARALLEL SPLIT
  PATH 1 {
    USER TASK LegalReview 'Legal review'
      PAGE Legal.ReviewPage
      OUTCOMES 'Approved' { };
  }
  PATH 2 {
    USER TASK FinanceReview 'Finance review'
      PAGE Finance.ReviewPage
      OUTCOMES 'Approved' { };
  };

Jump To

Jump to a named activity elsewhere in the workflow (creates a loop or skip):

JUMP TO <activity-name>;

Example:

JUMP TO ReviewTask;

Wait for Timer

Pause the workflow until a timer expression evaluates:

WAIT FOR TIMER [<name>] [<expression>] [COMMENT '<caption>'];

Example:

WAIT FOR TIMER addDays([%CurrentDateTime%], 3);

Wait for Notification

Pause the workflow until an external notification resumes it:

WAIT FOR NOTIFICATION;

Notification

An intermediate notification event (Mendix 11.11+): the point a NOTIFY WORKFLOW action targets by name.

NOTIFICATION [<name>] [CAPTION '<caption>'];

A notification boundary event attaches the same trigger to a user task, call microflow, call workflow or wait. It takes a name instead of a timer delay:

USER TASK Review 'Review'
  PAGE HR.ReviewPage
  OUTCOMES 'Approve' { } 'Reject' { }
  BOUNDARY EVENT INTERRUPTING NOTIFICATION Withdrawn 'Request withdrawn' {
    END WORKFLOW;
  };

An activity may carry only one interrupting boundary event (CE6697). ALTER WORKFLOW … { INSERT INTO <activity> { BOUNDARY EVENT … } } cannot add a notification boundary event yet.

A microflow reaches any of these with NOTIFY WORKFLOW, naming the element:

$Notified = NOTIFY WORKFLOW $Workflow TARGET HR.Leave.Withdrawn;

The target is required — a notify without one fails the build (CE0166). It may name a notification activity, a notification boundary event, a wait for notification, or the start of a notification-started event sub-process; mxcli works out which, and refuses anything a notification cannot reach.

Event Sub-Processes

A flow outside the main flow, started by its own start event while the workflow runs. Written after the main body, before END WORKFLOW:

EVENT SUBPROCESS <name> ['<caption>']
  ON (INTERRUPTING | NON INTERRUPTING) NOTIFICATION [<start>] ['<start caption>']
  { <activities> };

EVENT SUBPROCESS <name> ['<caption>']
  ON (INTERRUPTING | NON INTERRUPTING) TIMER <first-execution-time> [AS <start>] [CAPTION '<start caption>']
  { <activities> };

Example:

EVENT SUBPROCESS ESP_Cancel 'Cancel request'
  ON INTERRUPTING NOTIFICATION espCancelStart 'Cancel received' {
  CALL MICROFLOW HR.ACT_LogCancel;
};
  • Interrupting cancels every active path before the sub-process runs; non-interrupting runs alongside the main flow.
  • The body’s End is implicit, as in the main flow. A body that already ends (in END WORKFLOW, a JUMP TO, or branches that all end) gets none.
  • A JUMP TO must stay inside its own sub-process (CE6682).
  • A timer start needs its expression (CE0126).
  • Versions: notification starts need Mendix 11.8+, timer starts 11.13+.

End

Terminate the current workflow path:

END;

Typically used inside an outcome block to stop the workflow after a rejection or cancellation.

Notes (annotations)

Studio Pro’s notes attach to an activity. Write one as @annotation '…' on the line before the activity, as in a microflow; an event sub-process takes it before EVENT SUBPROCESS, and the note on the workflow itself is the header clause ANNOTATION '…':

CREATE WORKFLOW Module.Approve
  PARAMETER $WorkflowContext: Module.Request
  ANNOTATION 'Started from the request form'
BEGIN
  @annotation 'Escalates after two days'
  USER TASK Review 'Review' PAGE Module.Review_Task OUTCOMES 'Done' { };
END WORKFLOW;

An activity takes one note, and no other @ annotation. DESCRIBE WORKFLOW writes them the same way, so a replay keeps them. A standalone ANNOTATION '…'; statement in the body is refused (MDL-WF04): Mendix cannot load a note placed in the activity flow.

Summary Table

ActivityPurposePauses Workflow?
USER TASKWait for human actionYes
CALL MICROFLOWExecute server logicNo
CALL AGENT MICROFLOWRun an AI agent step (11.9+)No
CALL WORKFLOWStart sub-workflowDepends on sub-workflow
DECISIONBranch on conditionNo
PARALLEL SPLITConcurrent executionYes (waits for all paths)
JUMP TOGo to named activityNo
WAIT FOR TIMERDelay executionYes
WAIT FOR NOTIFICATIONWait for external signalYes
NOTIFICATIONIntermediate notification event (11.11+)Yes
EVENT SUBPROCESSFlow started by a notification or timer (11.8+)No (runs beside or replaces the main flow)
ENDTerminate pathN/A

See Also

Workflow vs Microflow

Workflows and microflows are both used to express business logic, but they serve different purposes and have different execution models.

Comparison

AspectMicroflowWorkflow
DurationMilliseconds to secondsHours, days, or weeks
ExecutionSynchronous, single transactionAsynchronous, persisted state
TriggerButton click, page load, eventExplicitly started, resumes on events
Human tasksNot supported (shows pages but does not wait)Built-in user task with outcomes
BranchingIF / ELSE within a single executionDecisions with separate outcome paths
ParallelismNot supportedParallel splits with multiple concurrent paths
PersistenceNo state between callsState persisted across restarts
MonitoringLog messages onlyAdmin overview page, task inbox
Error handlingON ERROR blocksOutcome-based routing

When to Use a Microflow

Use a microflow when:

  • The operation completes immediately (create, update, delete, calculate)
  • No human decision points are needed
  • The logic runs within a single database transaction
  • You need fine-grained error handling with rollback
CREATE MICROFLOW Shop.ACT_ProcessPayment
BEGIN
  DECLARE $Order Shop.Order;
  RETRIEVE $Order FROM Shop.Order WHERE [OrderId = $OrderId] LIMIT 1;
  CHANGE $Order (Status = 'Paid', PaidDate = [%CurrentDateTime%]);
  COMMIT $Order;
  RETURN true;
END;

When to Use a Workflow

Use a workflow when:

  • The process involves human approval or review steps
  • The process spans multiple days or longer
  • You need a visual overview of where each case stands
  • Multiple steps should happen in parallel
  • The process may wait for external events
CREATE WORKFLOW Shop.OrderApproval
  PARAMETER $Context: Shop.Order
  OVERVIEW PAGE Shop.OrderWorkflowOverview
BEGIN
  USER TASK ReviewOrder 'Review the order'
    PAGE Shop.OrderReviewPage
    OUTCOMES 'Approve' {
      CALL MICROFLOW Shop.ACT_ApproveOrder;
    } 'Reject' {
      CALL MICROFLOW Shop.ACT_RejectOrder;
      END;
    };
  CALL MICROFLOW Shop.ACT_FulfillOrder;
END WORKFLOW;

Combining Both

Workflows and microflows work together. A workflow orchestrates the process while microflows handle the actual logic at each step:

  1. Workflow defines the process: who needs to act, in what order, with what outcomes
  2. Microflows execute within workflow steps: create objects, send emails, update status
  3. Pages provide the UI for user tasks

A common pattern is:

-- Microflow does the work
CREATE MICROFLOW Approval.ACT_Approve
BEGIN
  DECLARE $Request Approval.Request;
  RETRIEVE $Request FROM Approval.Request WHERE [id = $RequestId] LIMIT 1;
  CHANGE $Request (Status = 'Approved', ApprovedBy = '[%CurrentUser%]');
  COMMIT $Request;
END;

-- Workflow orchestrates the process
CREATE WORKFLOW Approval.ApprovalProcess
  PARAMETER $Context: Approval.Request
BEGIN
  USER TASK Review 'Review request'
    PAGE Approval.ReviewPage
    OUTCOMES 'Approve' {
      CALL MICROFLOW Approval.ACT_Approve;
    } 'Reject' {
      CALL MICROFLOW Approval.ACT_Reject;
      END;
    };
END WORKFLOW;

See Also

Business Events

Business events enable event-driven integration between Mendix applications. An application can publish events when something happens (e.g., an order is placed) and other applications can subscribe to those events and react accordingly.

Business events are organized into event services, each of which defines one or more messages with typed attributes.

Inspecting Business Events

-- List all business event services
LIST BUSINESS EVENTS;
LIST BUSINESS EVENTS IN MyModule;

-- View full definition
DESCRIBE BUSINESS EVENT SERVICE MyModule.OrderEvents;

Quick Example

CREATE BUSINESS EVENT SERVICE Shop.OrderEvents (
  Version: '1.0',
  Description: 'Events related to order lifecycle'
) {
  MESSAGE OrderPlaced (
    OrderId: String,
    CustomerName: String,
    TotalAmount: Decimal,
    OrderDate: DateTime
  )
  MESSAGE OrderShipped (
    OrderId: String,
    TrackingNumber: String,
    ShippedDate: DateTime
  )
};

See Also

Event Services

An event service is a named container for business event messages. It defines the contract between publishers and consumers: what events exist and what data each event carries.

CREATE BUSINESS EVENT SERVICE

CREATE BUSINESS EVENT SERVICE <Module>.<Name> (
  [Version: '<version>',]
  [Description: '<text>']
) {
  MESSAGE <MessageName> (
    <AttributeName>: <Type> [, ...]
  )
  [MESSAGE <MessageName> ( ... )]
};
ElementDescription
VersionService version string
DescriptionHuman-readable description of the service
MESSAGEA named event type with typed attributes

Attribute Types

Message attributes support standard Mendix types:

TypeDescription
StringText value
Integer32-bit integer
Long64-bit integer
DecimalPrecise decimal number
BooleanTrue or false
DateTimeDate and time value

Example

CREATE BUSINESS EVENT SERVICE HR.EmployeeEvents (
  Version: '2.0',
  Description: 'Employee lifecycle events'
) {
  MESSAGE EmployeeHired (
    EmployeeId: String,
    FullName: String,
    Department: String,
    StartDate: DateTime
  )
  MESSAGE EmployeeTerminated (
    EmployeeId: String,
    TerminationDate: DateTime,
    Reason: String
  )
};

DROP BUSINESS EVENT SERVICE

Remove an event service:

DROP BUSINESS EVENT SERVICE HR.EmployeeEvents;

Listing and Inspecting

-- List all services
LIST BUSINESS EVENTS;

-- Filter by module
LIST BUSINESS EVENTS IN HR;

-- Full MDL definition
DESCRIBE BUSINESS EVENT SERVICE HR.EmployeeEvents;

The DESCRIBE output is round-trippable – it can be copied, modified, and executed as a CREATE statement.

See Also

Publishing and Consuming Events

Business events follow a publish-subscribe pattern. One application publishes events when significant actions occur, and other applications consume those events to trigger their own logic.

Publishing Events

A publishing application defines a business event service with its message types:

CREATE BUSINESS EVENT SERVICE Shop.OrderEvents (
  Version: '1.0',
  Description: 'Order lifecycle events'
) {
  MESSAGE OrderPlaced (
    OrderId: String,
    CustomerName: String,
    TotalAmount: Decimal,
    OrderDate: DateTime
  )
  MESSAGE OrderCancelled (
    OrderId: String,
    Reason: String,
    CancelledDate: DateTime
  )
};

The event service acts as a contract. Consuming applications rely on the message structure, so changes to attribute names or types should be accompanied by a version increment.

Consuming Events

A consuming application subscribes to events from another application’s event service. When an event is received, a configured microflow is triggered to handle it.

The consumption side is typically configured in Mendix Studio Pro by selecting the published event service and mapping each message to a handler microflow. The handler microflow receives the event attributes as parameters.

Handler Pattern

A typical handler microflow processes the incoming event data:

CREATE MICROFLOW Warehouse.ACT_HandleOrderPlaced
BEGIN
  -- Parameters: $OrderId (String), $CustomerName (String), etc.
  DECLARE $ShipmentRequest Warehouse.ShipmentRequest;
  $ShipmentRequest = CREATE Warehouse.ShipmentRequest (
    ExternalOrderId = $OrderId,
    CustomerName = $CustomerName,
    Status = 'Pending'
  );
  COMMIT $ShipmentRequest;
END;

Versioning

Event services include a version string to track contract changes:

CREATE BUSINESS EVENT SERVICE Shop.OrderEvents (
  Version: '2.0',
  Description: 'Order events - v2 adds ShippingAddress'
) {
  MESSAGE OrderPlaced (
    OrderId: String,
    CustomerName: String,
    TotalAmount: Decimal,
    OrderDate: DateTime,
    ShippingAddress: String
  )
};

When adding new attributes, increment the version so consuming applications know to update their handlers.

Use Cases

ScenarioPublisherConsumer
Order fulfillmentShop app publishes OrderPlacedWarehouse app creates shipment request
Customer syncCRM app publishes CustomerUpdatedBilling app updates customer record
ComplianceHR app publishes EmployeeTerminatedIT app revokes access
NotificationsAny app publishes eventsNotification service sends emails/SMS

See Also

Scheduled Events and Task Queues

Two Mendix features for work that runs outside a user request. They are unrelated and easy to confuse:

  • A scheduled event is Mendix’s cron — it runs a microflow on a repeating schedule.
  • A task queue bounds how many queued microflow calls run at once.

A scheduled event does not go through a task queue. Its own concurrency control is OnOverlap, which decides what happens when a run is still going when the next one is due.

Scheduled Events

Inspecting

-- All scheduled events, or one module's
LIST SCHEDULED EVENTS;
LIST SCHEDULED EVENTS IN Ops;

-- Re-executable MDL for one event
DESCRIBE SCHEDULED EVENT Ops.NightlyCleanup;

SHOW is accepted as a synonym for LIST.

CREATE SCHEDULED EVENT

CREATE [OR MODIFY] SCHEDULED EVENT <Module>.<Name> (
  Microflow: <Module>.<Microflow>,
  Repeat: <repeat>,
  <fields of that repeat>,
  <optional properties>
);

Microflow and Repeat are always required.

Repeat variants

The repeat rule is stored as one of eight Mendix schedule types, and they differ in which fields they carry — not just in their values. MDL mirrors that: each repeat takes only its own fields, and a field belonging to another repeat is refused by both mxcli check (rule MDL-SCHED01) and mxcli exec rather than silently dropped.

RepeatFieldsReads as
MinutelyMultiplierevery N minutes
HourlyMultiplier, MinuteOffsetevery N hours, at :MM
DailyHourOfDay, MinuteOfHourevery day at HH:MM
WeeklyWeekdays, HourOfDay, MinuteOfHouron the named days at HH:MM
MonthlyByDateMultiplier, MonthOffset, DayOfMonth, HourOfDay, MinuteOfHourthe Dth of every N months
MonthlyByWeekdayMultiplier, MonthOffset, DaySelector, Weekday, HourOfDay, MinuteOfHourthe last Friday of every N months
YearlyByDateMonth, DayOfMonth, HourOfDay, MinuteOfHourevery 2 January
YearlyByWeekdayMonth, DaySelector, Weekday, HourOfDay, MinuteOfHourthe first Monday of March

Field values:

FieldValue
Multiplierhow many units between runs (1 or more; defaults to 1)
MinuteOffset0–59, the minute past the hour
MonthOffset0-based, which month of a multi-month cycle fires
HourOfDay / MinuteOfHour0–23 / 0–59
DayOfMonth / Month1–31 / 1–12
Weekdaysa quoted list, e.g. 'Monday, Friday' (case-insensitive)
DaySelectorFirst, Second, Third, Fourth, Last
WeekdaySunday … Saturday

A value outside its range is an error, not a truncation: a schedule that is stored but can never fire is worse than a refusal, because nothing downstream reports it.

Optional properties

PropertyValuesDefault
Enabledtrue / falsefalse
OnOverlapDelayNext / SkipNextDelayNext
TimeZoneUTC / ServerUTC
StartDateTimean RFC 3339 timestamp; the event does not run before itnone
Documentationfree textnone

SkipNext drops a run that would overlap the previous one; DelayNext queues it until the previous one finishes.

Examples

mdl 1;
-- Every night at 04:00 in the server's timezone
CREATE SCHEDULED EVENT Ops.NightlyCleanup (
  Microflow: Ops.SE_Cleanup,
  Repeat: Daily,
  HourOfDay: 4,
  MinuteOfHour: 0,
  TimeZone: Server,
  Enabled: true
);

-- Every two hours, 23 minutes past
CREATE SCHEDULED EVENT Ops.HourlyPing (
  Microflow: Ops.SE_Ping,
  Repeat: Hourly,
  Multiplier: 2,
  MinuteOffset: 23
);

-- Mondays and Fridays at 09:30
CREATE SCHEDULED EVENT Ops.WeeklyReport (
  Microflow: Ops.SE_Report,
  Repeat: Weekly,
  Weekdays: 'Monday, Friday',
  HourOfDay: 9,
  MinuteOfHour: 30
);

-- The last Friday of every third month, at 18:00
CREATE SCHEDULED EVENT Ops.QuarterEnd (
  Microflow: Ops.SE_Close,
  Repeat: MonthlyByWeekday,
  Multiplier: 3,
  MonthOffset: 2,
  DaySelector: Last,
  Weekday: Friday,
  HourOfDay: 18
);

DROP SCHEDULED EVENT Ops.HourlyPing;

A note on Interval / IntervalType

Stored events also carry an Interval and IntervalType pair. These predate the Schedule child and Studio Pro does not keep them in sync with it — a real Mendix module ships an event storing 0 / Minute next to a daily schedule of 01:00. MDL has no syntax for them: a new event gets the pair that matches its repeat, and CREATE OR MODIFY carries whatever is stored through untouched. DESCRIBE reports them as a comment so the output stays re-executable.

Task Queues

A task queue bounds how many instances of a queued microflow call run at once.

[/** <documentation> */]
CREATE [OR MODIFY] TASK QUEUE <Module>.<Name> [(
  Parallelism: <expression>,
  ClusterWide: true|false
)];

LIST TASK QUEUES [IN <Module>];
DESCRIBE TASK QUEUE <Module>.<Name>;
DROP TASK QUEUE <Module>.<Name>;
PropertyMeaningDefault
Parallelismhow many tasks run at once — an expression, not a number1
ClusterWidetrue applies the limit across the cluster; false per runtime instancefalse

Mendix stores parallelism as an expression string, so a bare integer and a quoted one mean the same thing and an arbitrary expression is legal:

mdl 1;
CREATE TASK QUEUE Ops.OrderProcessing ( Parallelism: 3, ClusterWide: true );
CREATE TASK QUEUE Ops.Mail;                      -- defaults: 1, per-instance
CREATE OR MODIFY TASK QUEUE Ops.OrderProcessing ( Parallelism: '$MyModule.Workers' );

Binding a call to a queue is not yet expressible

MDL cannot yet author a queued call — the binding lives on the call activity inside a microflow, not on the queue. Because rebuilding a microflow would drop an existing binding, CREATE OR REPLACE|MODIFY MICROFLOW is refused when the stored microflow has a queued call, naming the queues that would be lost. Change those microflows in Studio Pro.

Without that refusal the binding was written back as null and the project then looked healthier than before — mx check stopped reporting CE1613 "The selected task queue no longer exists", because the configuration the error was about had been deleted.

Image Collections

Image collections are Mendix’s way of bundling images (icons, logos, graphics) within a module. Each collection can contain multiple images in various formats (PNG, SVG, GIF, JPEG, BMP, WebP).

Inspecting Image Collections

-- List all image collections across all modules
LIST IMAGE COLLECTIONS;

-- Filter by module
LIST IMAGE COLLECTIONS IN MyModule;

-- View full definition including embedded images
DESCRIBE IMAGE COLLECTION MyModule.AppIcons;

The DESCRIBE output includes the full CREATE statement. If the collection contains images, each is written into the statement as image Name ( Data: '<base64>' ), so the output can be copied and re-executed without the original files. In the TUI, images are rendered inline when the terminal supports it (Kitty, iTerm2, Sixel).

CREATE IMAGE COLLECTION

[/** <description> */]
CREATE IMAGE COLLECTION <Module>.<Name>
  [EXPORT LEVEL 'Hidden'|'Public']
  [{
    IMAGE <Name> ( File: '<path>' )
    ...
  }];
OptionDescriptionDefault
EXPORT LEVEL'Hidden' (internal to module) or 'Public' (accessible from other modules)'Hidden'
/** … */Documentation for the collection, as a doc comment before the statement (COMMENT '…' is its deprecated alias, MDL-DEPR100)(none)
IMAGE Name ( File: '…' )Load an image from the filesystem into the collection(none)
IMAGE Name ( Data: '…' [, Format: png] )The image itself, base64-encoded (what DESCRIBE writes); Format only when the bytes do not show it(none)

The images are the collection’s children, so they are in { }, each with its properties in ( ). The older form ( IMAGE Name FROM FILE '<path>', … ) still parses but warns (MDL-DEPR072); mxcli fmt --upgrade rewrites it.

The image format is detected automatically from the file extension. Relative paths are resolved against the script’s directory, then the current working directory. Supported formats: PNG, SVG, GIF, JPEG, BMP, WebP.

Examples

mdl 1;
-- Minimal: empty collection
CREATE IMAGE COLLECTION MyModule.AppIcons;

-- With export level
CREATE IMAGE COLLECTION MyModule.SharedIcons EXPORT LEVEL 'Public';

-- With documentation
/** Icons for order and task status indicators */
CREATE IMAGE COLLECTION MyModule.StatusIcons;

-- With images from files
CREATE IMAGE COLLECTION MyModule.NavigationIcons {
  IMAGE home ( File: 'assets/home.png' )
  IMAGE settings ( File: 'assets/settings.svg' )
};

-- All options combined
/** Company branding assets */
CREATE IMAGE COLLECTION MyModule.BrandAssets
  EXPORT LEVEL 'Public' {
  IMAGE logo_dark ( File: 'assets/logo-dark.png' )
  IMAGE logo_light ( File: 'assets/logo-light.png' )
};

DROP IMAGE COLLECTION

Remove a collection and all its embedded images:

DROP IMAGE COLLECTION MyModule.StatusIcons;

Icon collections (read-only)

Distinct from image collections, icon collections (CustomIcons$CustomIconCollection, e.g. Atlas_Core.Atlas_Filled) ship with the theme/Atlas. Their icons are referenced from a widget as Module.Collection.IconName — most commonly a button’s Icon: property. They’re read-only in mxcli; use SHOW / DESCRIBE to discover valid icon names (icons have non-obvious names — it’s add, not plus):

LIST ICON COLLECTIONS;                              -- name, prefix, export level, icon count
DESCRIBE ICON COLLECTION Atlas_Core.Atlas_Filled;   -- every icon + its reference form

Then use one on a button:

ACTIONBUTTON btnEdit (Caption: 'Edit', Action: PAGE App.Edit,
  Icon: 'Atlas_Core.Atlas_Filled.pencil')

See Also

Default Styling (mxcli theme)

A blank Mendix app looks like a blank Mendix app. mxcli new applies a default theme so a generated app looks like a product on first boot, and mxcli theme apply adds the same theme to a project you already have.

mxcli theme list                        # built-in themes; the default is marked *
mxcli theme show signal                 # palette, colorway, tokens, files it writes
mxcli theme apply -p app.mpr            # apply to an existing project
mxcli theme apply ledger -p app.mpr     # switch theme (the previous one is removed)
mxcli theme apply signal ledger console -p app.mpr   # a switchable set
mxcli theme apply -p app.mpr --dry-run  # report changes without writing
mxcli theme remove -p app.mpr           # take it back out

mxcli theme create acme -p app.mpr      # a theme this project owns
mxcli theme list -p app.mpr             # ...which then lists beside the built-ins

mxcli new MyApp --version 11.13.0                # applies `signal`
mxcli new MyApp --version 11.13.0 --theme none   # plain Atlas

The themes

NameDefault paletteCharacter
signal (default)lightCool slate, one teal signal colour, 4px radius, 32px rows, IBM Plex
ledgerlightWarm paper, hairline rules instead of card shadows, Source Serif headings over Source Sans, 2px radius, 30px rows
consoledarkNear-black ground, teal with a violet accent, Space Grotesk over JetBrains Mono, 6px radius, 28px rows, surfaces separated by lightness

All three share the same density discipline: an 8px spacing unit, monospace numerics, a visible focus ring on every focusable element, and every control growing to a 44px touch target below 768px.

Several themes at once, switchable at runtime

mxcli theme apply signal ledger console -p app.mpr

All three compile into one stylesheet and the app switches between them by a class on <html> — no rebuild, no reload, no server round trip. The first named renders by default.

That works because a theme is almost entirely token values. The Atlas wiring, the recipe layer and the widget layer are byte-identical in every theme and resolve every colour through var(--mxt-*), so one copy of them serves the whole set; only the palette, the fonts and a few skin rules differ. Each palette is declared on its own root selector:

:root:not(.mxt-ledger):not(.mxt-console), :root.mxt-signal { --mxt-brand: #0f6e6b; … }
:root.mxt-ledger  { --mxt-brand: #1f3a5f; … }
:root.mxt-console { --mxt-brand: #2dd4bf; … }

The default claims :root minus every other skin’s class rather than a bare :root. Bare would keep matching once another class was set, so its rules would leak under every other theme and the winner would come down to specificity. Negation makes the scopes mutually exclusive: exactly one palette is ever live, and the result never depends on import order.

Measured on a real 11.13 app, same page, class swap only:

brandgroundradiustypecards
signal (default)#0f6e6b#f4f6f84pxIBM Plex Sansshadow
ledger#1f3a5f#f7f4ee2pxSource Sans 3hairline
console#2dd4bf#0e11166pxSpace Groteskflat

Light/dark is a separate axis and keeps working: each theme brings both palettes, so picking a theme preserves the variant and vice versa.

A single theme is still emitted exactly as before — a bare :root, no scoping — so nothing changes for a project that installs one.

Driving it from the app

mxcli theme apply signal ledger console -p app.mpr
mxcli theme switcher install -p app.mpr --module MyFirstModule

With a set installed, switcher install also generates SetAppSkin, CycleAppSkin, ApplyStoredSkin and a ACT_CycleSkin nanoflow, built against the set that is actually installed — so a cycle button can never offer a theme whose CSS is not in the page.

actionbutton btnSkin (caption: 'Theme', action: call nanoflow MyFirstModule.ACT_CycleSkin)

The same reload caveat as the light/dark toggle applies: the choice is remembered in localStorage but nothing re-applies it on load, because Mendix has no page on-load event mxcli can author. ApplyStoredSkin is installed and ready to wire in Studio Pro.

What it writes

Seven things, all under theme/:

FileWhat
theme/web/custom-variables.scssthe theme’s palette — this is the file to edit
theme/web/_mxcli-atlas-map.scssthe Atlas wiring: ~60 Atlas variables expressed in terms of the palette
theme/web/_mxcli-<name>.scssthe other palette, the variant blocks, @font-face, this theme’s skin
theme/web/_mxcli-recipes.scssthe recipe layer: num / pill / stat / focus / density — shared
theme/web/_mxcli-widgets.scssthe widget-module layer: colours Sass bakes before any token exists
theme/web/main.scssthe variant switch plus the @import lines
theme/web/mxcli-fonts/vendored fonts, each theme’s own OFL-<name>.txt (SIL OFL 1.1)

The model is never touched. No .mpr changes, so nothing here can affect a build, and the theme hot-applies under mxcli run --local --watch.

Atlas Core is never touched either. Because Atlas components read the brand tokens, retuning them cascades into buttons, backgrounds, form inputs, cards, modals and the brand-aware pluggable widgets (Switch, Slider, ProgressBar, BadgeButton) with no per-widget CSS. That is also why the project stays upgradable across Mendix releases.

Light and dark

--variant auto is the default and ships both palettes:

mxcli theme apply signal -p app.mpr                  # auto
mxcli theme apply signal -p app.mpr --variant dark   # bake one palette, no switching

Under auto the app follows the operating system’s prefers-color-scheme before first paint — no flash, no script — and honours a theme-light or theme-dark class on the root element when something sets one.

Mendix ships that slot (theme/web/_theme-dark.scss declares :root.theme-dark) but nothing that applies it, and its palette is stock Mendix blue. An mxcli theme re-declares the same selector from a file that compiles later, so the theme’s own dark palette wins.

A user-facing toggle

mxcli theme switcher install -p app.mpr --module MyFirstModule

This is the one theme command that writes to the model. It has to: the class has to be set by something the browser can run, and there is no theme-level hook to run script before first paint. It creates three JavaScript actions (ToggleAppTheme, SetAppTheme, ApplyStoredTheme) and a nanoflow, then you wire a button:

actionbutton btnTheme (caption: 'Theme', action: call nanoflow MyFirstModule.ACT_ToggleTheme)

A click flips the palette and remembers the choice in localStorage. The class goes on <html>, so popups and modals — which Mendix renders at <body>, outside any page container — follow it too.

Known limit: after a reload the app goes back to following the OS. Mendix has no page on-load event to re-apply the stored value, and the usual substitute (a data view with a nanoflow data source) is not authorable by mxcli on either engine yet. ApplyStoredTheme is installed and ready — wire it in Studio Pro if you need the choice to persist across reloads.

A theme this project owns

The three built-in themes are a house style, not a brand. theme create scaffolds a fourth into theme/mxcli-themes/<name>/, and from then on it is a theme like any other — theme list -p shows it, theme apply <name> installs it, theme remove takes it out.

mxcli theme create acme -p app.mpr                        # scaffold from signal
mxcli theme create acme -p app.mpr --from console         # ...or from console
mxcli theme create acme -p app.mpr --from design.css      # ...and seed the palette
mxcli theme apply acme -p app.mpr

That folder is the right place because of two constraints. It is committed — a theme derived from a design is source the team shares, which rules out .mxcli/, that mxcli init puts in .gitignore. And it is not compiled — mxbuild’s entry point is theme/web/main.scss and it does not glob theme/ for other stylesheets, so the sources sit inert until theme apply copies them into theme/web/. (Verified against a real 11.13 build: nothing under theme/mxcli-themes/ reaches deployment/.)

Scaffolding copies an existing theme rather than starting from nothing, so the Atlas wiring and the widget layer — identical in every theme, and where most of the hard-won detail lives — come across byte for byte. What you edit is the palette. A local theme named after a built-in shadows it, so “signal, but with our brand” is a valid thing to create.

Seeding a palette from a design

A theme’s palette is nothing but --mxt-* custom properties, so a design tool seeds one by declaring them. That is the whole contract — anywhere in a .css, .scss or .html file:

:root { --mxt-brand: #7f5af0; --mxt-ground: #fffffe; }
@media (prefers-color-scheme: dark) { :root { --mxt-ground: #16161a; } }

Seeding also updates what the new theme says about itself, because a value inherited from the base describes a palette that is no longer there:

  • the colorway theme list shows is derived from the seeded brand/info/success/warning/danger/ink-muted, falling back to the base’s for any the design did not declare (a partial seed is normal);
  • the summary names the design file it came from, rather than keeping the base’s description of its own colours. Pass --summary for a real one.

And if the seeded fonts no longer name a family the base theme vendors, that family’s @font-face rules and its .woff2 files are dropped together — per family, so a theme that changes its body font and keeps IBM Plex Mono for code ships only the mono weights. A design that says nothing about fonts keeps them all, which is the right default: dropping a font nobody asked to change would break the scaffold’s own rendering.

Declarations inside a dark block (prefers-color-scheme: dark, .theme-dark, [data-theme="dark"]) seed the dark palette; everything else seeds the light one. Tokens the design does not name keep the base theme’s value, so a three-colour design still yields a complete, working palette.

A design with no block for the base theme’s other variant — only a base palette — leaves that variant exactly as the base theme ships it, and create prints a note naming the mixin. The base palette describes one variant: copying its light ground and ink into the dark palette, whose other surfaces stay dark, gave an unreadable mix. Add a prefers-color-scheme: dark (or light, for a dark-first base such as console) block to seed it.

mxcli theme show signal prints the vocabulary. A --mxt-* name the base theme does not declare is an error, not an extra: nothing would read it, so the theme would apply cleanly and render unchanged — the one failure mode this path exists to avoid.

Inferring a palette from a mockup’s inline styles was the alternative, and it is a guess: two greys in a design do not say which is the app ground and which is a hovered row. Ask the design step to emit a token block instead.

Re-branding

Change one line in theme/web/custom-variables.scss:

:root {
  --mxt-brand: #0f6e6b;   /* <- the one signal colour */

_mxcli-atlas-map.scss maps that onto --brand-primary, and Atlas builds the whole derived ramp (--brand-primary-50 … -900) from it with CSS color-mix() — so buttons, links, active navigation and the brand-aware pluggable widgets follow immediately, in both palettes.

The palette file declares only --mxt-* tokens, never Atlas variables directly. That is what makes a variant cheap: the dark block restates about thirty values instead of rewiring sixty. Pinning an Atlas variable to a literal colour is the one thing that breaks switching — a hardcoded --font-color-default is invisible the moment the ground goes dark.

Import order in main.scss

apply appends its block to the end of theme/web/main.scss, so it lands after any @import the project already had there. If your app carries a stylesheet imported last specifically to win the cascade over Atlas, the theme’s partial now comes after it. Higher-specificity app classes are unaffected; a rule that relied purely on being last is not. Move your @import below the mxcli block — anything outside the fence is never touched.

Two Atlas constraints worth knowing

  • The navigation rail stays dark in both palettes. Atlas topbar widgets paint their own text assuming a dark rail — the language selector uses --bg-color-secondary with a #fff fallback, at a specificity (.navbar-brand .widget-language-selector .current-language-text) that a simple override cannot beat. Every mxcli theme keeps the rail dark and re-declares those selectors at matching specificity, resolving the colour through the rail token.
  • themesource/<name>/ is only compiled when <name> matches a real module, so a theme never writes there. theme/web/main.scss compiles last and is the correct home for app-level styling.
  • The widget modules bake some colours as Sass literals, before any custom property exists, so no token can move them — the Data Grid 2 pager caption is the worst case, at 1.02:1 on a dark ground. _mxcli-widgets.scss corrects those with ordinary rules; it is regenerated with the theme, so leave it alone and put your own overrides outside the fence.

Recipe classes

Apply with class: on any widget.

ClassUse
nummonospace with tabular figures — ids, amounts, dates, so columns align
num-rightright-align a numeric column
pill + pill-ok / pill-warn / pill-risk / pill-infostatus pills; pair with a dynamicclasses expression mapping an enum
stat + stat-label / stat-value / stat-delta / stat-delta-up / stat-delta-downKPI tile
density-compact28px rows and inputs inside this container

Your edits are safe

Every generated region is fenced, and the closing marker records a digest of what mxcli wrote:

// mxcli:theme:begin signal v1 — generated by `mxcli theme apply`; edit outside this block
…
// mxcli:theme:end signal b538a13336af87f6
  • Edit outside the markers and mxcli never touches your lines.
  • Edit inside them and the next apply refuses rather than discarding your work, naming the file and offering --force.
  • apply is idempotent — re-running an unchanged theme reports unchanged for every file.
  • remove cuts the blocks back out and restores the file byte for byte.

Verifying

A theme that compiles cleanly can still render wrong, so check a running app rather than the build log:

mxcli run --local --watch --screenshot -p app.mpr

SCSS edits hot-apply, so --watch gives you a tight loop. If a rule seems not to apply, first confirm it is compiled at all — grep for the selector in theme-cache/web/theme.compiled.css. Absent and overridden look identical in the browser, and only one of them is a specificity problem.

See also the atlas-design skill for the method behind the theme, and theme-styling for the SCSS compilation chain — both are installed into your project by mxcli init.

Domain Model Layout

A domain model generated from an MDL script has to be placed somewhere, and MDL says nothing about placement unless you write @position on every entity. mxcli layout arranges a module from its association graph, so you do not have to.

mxcli layout -p app.mpr --module Sales --dry-run   # list the moves
mxcli layout -p app.mpr --module Sales             # apply them
mxcli layout -p app.mpr                            # every module the project owns

What it does

Entities are layered on the associations between them:

  • an entity that references nothing else in the module is a lookup, and goes in the leftmost column;
  • everything else sits one column past the furthest thing it references;
  • entities with no association at all — non-persistent helpers, mostly — go in a band underneath, rather than being mixed in with the lookups.

Association lines then mostly run one way instead of crossing the diagram.

On a real 16-entity model the layering falls out of the associations with no hints:

columnentities
1Department, EmploymentType, MovementReason, PlanType, PlanningYear, Region
2Team, GoalBucket
3GoalChange, GoalRegionValue, PlanScope, CapTrackUser
4Employee, ScopeMonth
5EmployeeMonth, Movement

It replaces positions you set by hand

This is the reason it is a command you run rather than something exec does on its own. Inside the modules it touches, every entity is repositioned — including any you arranged yourself in Studio Pro. Use --dry-run first; it prints every move as Module.Entity (x, y) -> (x, y) and writes nothing.

To repair a model you arranged by hand, use --only-overlapping instead: an entity whose box does not collide with another entity or an annotation keeps its position, and of two that collide the first in reading order (top to bottom, then left to right) stays put while the other moves to the nearest free spot. Annotations are obstacles and never move.

$ mxcli layout -p app.mpr --module Sales --only-overlapping --dry-run
  Sales.Small7  (100, 380) -> (100, 480)
Sales: 1 of 7 entities would move

Studio Pro does not store an entity’s size, so box sizes are estimated from the entity’s name and attribute count, generously.

Marketplace modules and System are never touched. Naming one explicitly is an error rather than a silent skip:

$ mxcli layout -p app.mpr --module Administration
Administration comes from the Marketplace, and a module update replaces it —
pass --include-marketplace to lay it out anyway

Running it more than once

Positions are a function of the model alone, so the command is safe to leave in a build script:

  • Idempotent. A second run reports already laid out and does not write, so it produces no version-control noise.
  • Local. Adding an entity and re-running moves only what the new relationships require — measured at 3 of 17 for one added entity with one association, not a reshuffle of the diagram.

Flags

FlagMeaning
--module <name>Lay out this module (repeatable). Default: every module the project owns.
--dry-runPrint the moves, write nothing.
--only-overlappingMove only entities that overlap another entity or an annotation; keep every other position.
--include-marketplaceAlso lay out Marketplace modules. A module update replaces them, so this is normally pointless.

When you do want explicit positions

@position(x, y) on CREATE ENTITY, and ALTER ENTITY … SET POSITION (x, y) to move one afterwards, both still work — and describe entity emits the stored position, so arranging a model and describing it back is a way to capture a layout into MDL.

Two things to know if you place entities yourself:

  • The coordinate is the box’s top-left corner, not its centre.
  • An entity created with no position takes the next free slot in a wrapping grid — the first slot where its box, at its estimated height, clears every entity and annotation already in the module. That is a default, not a layout: it keeps a large model on screen and stops boxes overlapping, but it knows nothing about which entities are related. View entities, OData external entities and entities imported from a contract are placed the same way.

Default position for an entity with no @Position

A wrapping grid (mdl/dmlayout), not the single 6,000px row it used to be.

Microflow and Nanoflow Layout

mxcli layout flows re-arranges existing microflows and nanoflows on their canvas. It is the flow counterpart of mxcli layout, and it is how you “reset the layout” of a flow: there is no clause on CREATE MICROFLOW for that, because layout is not part of what a flow does.

mxcli layout flows -p app.mpr Sales.ACT_Order_Submit        # one flow
mxcli layout flows -p app.mpr --module Sales --dry-run      # list what would move
mxcli layout flows -p app.mpr --module Sales                # every flow in a module

The same layout as CREATE

There is one layout engine for flows, and this command uses it. A flow laid out here ends up exactly where the same flow would be if you created it from MDL with no @position: main path left to right, guard branches in the lane below, long rows wrapped, loops sized to their bodies. See Microflow Structure for the rules.

The flow is described to MDL, every layout annotation (@position, @anchor, @curve, @merge, @start, and the placement of @annotation notes) is dropped, and it is built again as CREATE would build it. Only the geometry of that build is kept.

Choosing a layout style

Teams draw flows differently, so the style is a project setting. Put a layout-config.yaml in the project’s .claude/ directory or next to the .mpr and commit it — everyone working on the project then lays flows out the same way:

flows:
  preset: true-right-false-down
PresetWhat it draws
happy-line (default)The TRUE path on one horizontal line, the ELSE branch in a lane under it, left to right.
true-right-false-downThe TRUE path on the line; the ELSE branch stacked top to bottom as a column under its decision, rejoining the merge from below. A CASE or split by type the same way: its first outcome on the line, every other outcome a column under it.

A preset is a set of options, and an option overrides its preset:

OptionValues
if-elselanes (default), true-right-else-column
case-splitlanes (default): a CASE’s or type split’s outcomes are stacked as lanes, each left to right. first-right-rest-columns: the first outcome, as written, stays on the line; every other one is a column under the decision, side by side in the order written. Put the happy case first.
wraprows (default): past the row width the main line continues on a new row, back at the left. off: the main line never wraps.
max-row-widthWhere a row wraps, in px, from 640 to 20000. Default 2880 (two screens of Studio Pro canvas).
guardreturn-below (default): a guard’s early return goes in the lane below and the happy path stays on the line. true-right: the early return carries straight on to the right, and the happy path drops from the decision’s bottom onto a new line below.

A guard is an if … then … return; end if with no else. It has its own option because teams that want TRUE on the right for IF/ELSE mostly still want their guards to keep the happy path straight.

An unknown key or value is an error, not a silent fallback to the default. mxcli layout config -p app.mpr shows the style in force and the file it came from. CREATE MICROFLOW / CREATE NANOFLOW without @position use it too.

To try a style without committing it, pass --style:

mxcli layout flows -p app.mpr --module Sales --style true-right-false-down --dry-run

The style a flow was drawn in travels with it

A flow laid out in a style other than the default gets one line at the end of its documentation, which Studio Pro shows in the documentation pane:

@mxcli-layout: if-else=true-right-else-column

DESCRIBE and CREATE OR MODIFY use that line for that flow, whatever the project file says now. So a flow drawn in one style describes without @position noise after the project switches to another, and its description rebuilds the same geometry. Laying a flow out in the default style removes the line. If the line is deleted or unreadable, nothing breaks: DESCRIBE then prints the positions it can no longer derive, and a warning says why.

What changes

The result is patched onto the stored flow rather than written in its place, so nothing but layout changes:

  • positions and sizes of activities, events, splits, merges and loops;

  • positions of parameters and notes (a note keeps its size);

  • the connection sides and curves of sequence flows.

  • the @mxcli-layout: line in the flow’s documentation (above) — the rest of the documentation is left byte for byte.

Element IDs, captions, expressions and every property MDL cannot express are left exactly as stored. That is also why this works on flows drawn in Studio Pro.

Flows it will not touch

The rebuilt flow has to match the stored one object for object. If it does not — the flow uses something MDL cannot yet express the same way, such as several branches sharing one merge — the flow is skipped with the reason, and the rest of the batch carries on:

FeedbackModule.VAL_Feedback: skipped — its ExclusiveMerge came back as a
ValidationFeedbackAction activity after a rebuild, so it does not round-trip
through MDL

A merge with one flow in and one flow out joins nothing, so it is not a reason to skip: it is placed on the edge it sits on.

It replaces positions you set by hand

Every flow it touches is re-arranged, including any you arranged yourself. Use --dry-run first. Marketplace modules and System are never touched unless you pass --include-marketplace, whether you name a module or a single flow.

Running it again changes nothing: a second run reports already laid out and writes nothing.

Flags

FlagMeaning
Module.Flow ...Lay out these microflows or nanoflows.
--module <name>Lay out every microflow and nanoflow in this module (repeatable).
--dry-runReport what would move, write nothing.
--style <preset>Lay out in this preset instead of the project’s layout-config.yaml.
--include-marketplaceAlso lay out flows in Marketplace modules. A module update replaces them, so this is normally pointless.

Either name flows or pass --module; laying out every flow in a project is too broad to do by default.

Code Navigation

mxcli provides a suite of cross-reference navigation commands that let you explore relationships between elements in a Mendix project. These commands answer questions like “what calls this microflow?”, “what would break if I change this entity?”, and “what does this element depend on?”

Prerequisites

Cross-reference navigation commands require a full catalog refresh before use:

REFRESH CATALOG FULL;

This populates the reference data needed for callers, callees, references, impact, and context queries. A basic REFRESH CATALOG only builds metadata tables and is not sufficient for cross-reference navigation.

Available Commands

CommandPurpose
LIST CALLERS OFFind what calls a given element
LIST CALLEES OFFind what a given element calls
LIST REFERENCES TOFind all references to and from an element
LIST IMPACT OFAnalyze the impact of changing an element
DESCRIBE CONTEXT OFShow the surrounding context of an element
SEARCHFull-text search across all strings and source

CLI Subcommands

These commands are also available as CLI subcommands for scripting and piping:

mxcli callers -p app.mpr Module.MyMicroflow
mxcli callees -p app.mpr Module.MyMicroflow
mxcli refs -p app.mpr Module.Customer
mxcli impact -p app.mpr Module.Customer
mxcli context -p app.mpr Module.MyMicroflow --depth 3
mxcli search -p app.mpr "validation"

Typical Workflow

  1. Refresh the catalog to ensure cross-reference data is current
  2. Explore references to understand how elements are connected
  3. Analyze impact before making changes
  4. Use context to gather surrounding information for AI-assisted development
-- Step 1: Build the catalog
REFRESH CATALOG FULL;

-- Step 2: Understand what uses a microflow
LIST CALLERS OF Sales.ACT_ProcessOrder;

-- Step 3: Check impact before renaming an entity
LIST IMPACT OF Sales.Customer;

-- Step 4: Gather context for AI consumption
DESCRIBE CONTEXT OF Sales.SubmitOrder;

How AI Assistants Use This

When you ask an AI assistant to modify an element, it uses these commands to:

  1. Discover dependencies with LIST CALLERS and LIST REFERENCES
  2. Assess risk with LIST IMPACT before making changes
  3. Gather context with DESCRIBE CONTEXT for informed code generation
  4. Find related elements with SEARCH to understand patterns

LIST CALLERS / CALLEES

These commands trace the call graph of your Mendix project, showing what calls a given element and what that element calls.

Prerequisites

Both commands require a full catalog refresh:

REFRESH CATALOG FULL;

LIST CALLERS OF

Shows all elements that call or reference a given element.

Syntax:

LIST CALLERS OF <qualified-name>

Examples:

-- Find everything that calls a microflow
LIST CALLERS OF Sales.ACT_ProcessOrder;

-- Find what references an entity
LIST CALLERS OF Sales.Customer;

-- Find what uses a page
LIST CALLERS OF Sales.CustomerOverview;

CLI Usage

# Basic caller lookup
mxcli callers -p app.mpr Module.MyMicroflow

# Transitive callers (full call chain)
mxcli callers -p app.mpr Module.MyMicroflow --transitive

The --transitive flag follows the call chain recursively, showing not just direct callers but also callers of callers.

LIST CALLEES OF

Shows all elements that a given element calls or references.

Syntax:

LIST CALLEES OF <qualified-name>

Examples:

-- Find what a microflow calls
LIST CALLEES OF Sales.ACT_ProcessOrder;

-- Find what entities a microflow uses
LIST CALLEES OF Sales.SubmitOrder;

CLI Usage

mxcli callees -p app.mpr Module.MyMicroflow

Use Cases

Before Refactoring

Check what calls a microflow before renaming or modifying it:

LIST CALLERS OF Sales.ACT_OldName;
-- Review the list of callers before making changes

Understanding Dependencies

Trace the full call chain of a complex microflow:

LIST CALLEES OF Sales.ACT_ProcessOrder;
-- Shows: Sales.ValidateOrder, Sales.UpdateInventory, Sales.SendConfirmation

Finding Dead Code

If LIST CALLERS returns no results, the element may be unused:

LIST CALLERS OF Sales.ACT_UnusedMicroflow;
-- Empty result = potentially dead code

LIST REFERENCES / IMPACT

These commands provide broader reference tracking and impact analysis, helping you understand how changes propagate through a project.

Prerequisites

Both commands require a full catalog refresh:

REFRESH CATALOG FULL;

LIST REFERENCES TO

Shows all references to and from a given element, combining both incoming and outgoing relationships.

Syntax:

LIST REFERENCES TO <qualified-name>

Examples:

-- Find all references to/from an entity
LIST REFERENCES TO Sales.Customer;

-- Find all references to/from a microflow
LIST REFERENCES TO Sales.ACT_ProcessOrder;

-- Find all references to/from a page
LIST REFERENCES TO Sales.CustomerOverview;

CLI Usage

mxcli refs -p app.mpr Module.Customer

Difference from CALLERS/CALLEES

While LIST CALLERS and LIST CALLEES focus on the call graph direction, LIST REFERENCES combines both directions into a single view. It shows every element that either references or is referenced by the target element.

LIST IMPACT OF

Performs impact analysis on an element, showing what would be affected if you changed or removed it.

Syntax:

LIST IMPACT OF <qualified-name>

Examples:

-- Analyze impact of changing an entity
LIST IMPACT OF Sales.Customer;

-- Analyze impact before removing a microflow
LIST IMPACT OF Sales.ACT_CalculateTotal;

-- Check impact before moving an element
LIST IMPACT OF Sales.CustomerOverview;

CLI Usage

mxcli impact -p app.mpr Module.Customer

Use Cases

Pre-Change Impact Assessment

Before modifying an entity, check what would be affected:

-- What depends on the Customer entity?
LIST IMPACT OF Sales.Customer;

-- Review results: pages, microflows, associations, access rules
-- Then decide if the change is safe to make

Before Moving Elements

Moving elements across modules changes their qualified name and can break references:

mdl 1;
-- Check impact before moving
LIST IMPACT OF OldModule.Customer;

-- If impact is acceptable, proceed
MOVE ENTITY OldModule.Customer TO NewModule;

Finding All Usages of an Enumeration

LIST REFERENCES TO Sales.OrderStatus;
-- Shows: entities with attributes of this type, microflows that use it, pages that display it

Dependency Mapping

Understand the full dependency web of a complex module:

LIST REFERENCES TO Sales.Order;
LIST REFERENCES TO Sales.OrderLine;
LIST REFERENCES TO Sales.Order_Customer;

DESCRIBE CONTEXT

The DESCRIBE CONTEXT command assembles the surrounding context of an element within its module and project hierarchy. This is particularly useful for AI-assisted development, where an LLM needs to understand the neighborhood of an element to generate accurate code.

Prerequisites

Requires a full catalog refresh:

REFRESH CATALOG FULL;

Syntax

DESCRIBE CONTEXT OF <qualified-name>

Examples

-- Describe context of a microflow
DESCRIBE CONTEXT OF Sales.ACT_ProcessOrder;

-- Describe context of an entity
DESCRIBE CONTEXT OF Sales.Customer;

-- Describe context of a page
DESCRIBE CONTEXT OF Sales.CustomerOverview;

CLI Usage

# Basic context retrieval
mxcli context -p app.mpr Module.MyMicroflow

# With depth control
mxcli context -p app.mpr Module.MyMicroflow --depth 3

# MDL sources in the alpha language (mdl 0) instead of mdl 1
mxcli context -p app.mpr Module.MyMicroflow --mdl 0

The MDL sources are written in mdl 1, each headed by mdl 1;.

The --depth flag controls how many levels of related elements are included in the context output. A higher depth gives a broader picture but produces more output.

What Context Includes

The context command gathers information about:

  • The element’s own definition (MDL source)
  • Elements that call it (callers)
  • Elements it calls (callees)
  • Related entities and associations
  • Pages that display or edit the same data
  • The module structure surrounding the element

Use Case: AI-Assisted Development

When an AI assistant needs to modify a microflow, it uses DESCRIBE CONTEXT to understand:

  1. What parameters the microflow expects
  2. What entities and associations are involved
  3. What other microflows it interacts with
  4. What pages display the same data

This information allows the AI to generate code that fits naturally into the existing project structure.

# Gather context for an AI prompt
mxcli context -p app.mpr Sales.ACT_ProcessOrder --depth 2

Graph Analysis

mxcli models a Mendix project as a dependency graph — documents (entities, microflows, pages, …) are nodes and their references (CATALOG.REFS) are edges — and runs topological analyses on top of it: god nodes, module coupling, community detection, dependency cycles, layering, and centrality.

Use it to understand an unfamiliar app and decide where to intervene: what’s central and risky to change, which modules are entangled, what naturally belongs together, and what it would take to split the app.

All of this is pure-Go (no external dependencies) and reads the catalog, so it needs no Studio Pro and no cloud connectivity. It requires a full catalog (the graph lives in the refs table, built by refresh catalog full).

mxcli graph-report — the architecture map

A one-shot Markdown/JSON report rendered from the CATALOG.graph_* views:

mxcli graph-report -p app.mpr                 # markdown to stdout
mxcli graph-report -p app.mpr --top 25        # more rows per section
mxcli graph-report -p app.mpr --format json -o graph.json
mxcli graph-report -p app.mpr --include-framework   # keep System/Atlas/connectors

Sections: god nodes (degree centrality), module coupling (cross-module “surprise edges”), module cohesion (intra/inter ratio), dead documents (no inbound reference), reference kinds (the edge vocabulary), and entity hotspots (entities used by the most flows). Framework/marketplace modules are excluded by default — they dominate the raw top-N but aren’t actionable.

Every section is a thin SELECT over a view, so it is reproducible directly:

select * from CATALOG.graph_god_nodes order by Degree desc limit 20;

Community detection, cycles, layers, centrality

These need a graph algorithm, so they are computed on demand and stored:

mxcli -p app.mpr -c "refresh catalog communities"               # default resolution
mxcli -p app.mpr -c "refresh catalog communities resolution 0.6"  # coarser (fewer, larger)
mxcli -p app.mpr -c "refresh catalog communities resolution 2.0"  # finer (more, smaller)

This runs Leiden community detection, Tarjan strongly-connected components (cycles), topological layering, PageRank, and betweenness, then populates these catalog objects (and fills the PageRank/Betweenness columns of graph_god_nodes):

ObjectContents
CATALOG.communitiescommunity id per asset
CATALOG.community_summaryper-community size, dominant-module label, members
CATALOG.graph_cyclesassets in a dependency cycle (structural kinds only)
CATALOG.graph_module_cyclesmodules in a dependency cycle (every kind)
CATALOG.graph_layerstopological layer sequence number per asset
CATALOG.graph_centralityPageRank / betweenness per asset
CATALOG.graph_module_dependenciesdirected module→module edges (kind + count)
CATALOG.graph_integration_surfacecross-community edges → integration mechanism

The resolution knob selects granularity: high γ → fine candidate modules; low γ → coarse candidate apps.

Two cycle tables, two questions

graph_cycles and graph_module_cycles are not a detail view and a rollup of each other. Modules A and B are in a cycle when A references B and B references A — through any documents, which need form no cycle between themselves. That is the ordinary shape, so graph_cycles is legitimately empty for a genuinely tangled pair of modules. Reading it as “no circular dependencies” while graph_module_coupling listed both directions is mendixlabs/mxcli#1060.

They also read different edges, on purpose:

edge setasks
graph_cyclesstructural kinds onlywhich documents are tangled together
graph_module_cyclesevery kind, like graph_module_couplingwhich modules cannot be extracted independently

The asset graph excludes navigational kinds (layout, show_page, snippet_call, datasource, widget, sync, …) because they blur community and layer clustering. A module cycle is a different claim: a page bound to a layout in another module really is a dependency of that module, and on a blank Mendix app Administration → Atlas_Core is a layout edge and nothing else. A module-cycle table on the structural subset would answer “none” for exactly the pair that gets reported.

graph_analysis_scope makes the split answerable instead of buried in source — one row per reference kind with its edge count and whether it reaches the asset graph:

select * from CATALOG.graph_analysis_scope order by InAssetGraph, Edges desc;

On a stock 11.14 app that is 110 of 316 edges outside it. Each graph_module_cycles row also carries RefKinds — the kinds on that module’s edges into the rest of the cycle, so you know which reference to go and break.

select ModuleName, CycleSize, RefKinds from CATALOG.graph_module_cycles
  order by CycleSize desc, ModuleName;

If one of these is empty

Empty means one of two very different things, and they used to look identical. The tables above are filled by the pass, not by a build mode — so a catalog built with refresh catalog full has all of them empty while graph_module_coupling, a plain view over refs, answers normally. That asymmetry was mendixlabs/mxcli#1060.

A query now says which case you are in:

Warning: CATALOG.GRAPH_CYCLES requires refresh catalog communities (not run for this catalog)

No warning and no rows means the pass ran and genuinely found nothing. show catalog status reports the same thing up front:

Graph analysis: ✓ Available (resolution 1)
Graph analysis: ✗ Not run (use refresh catalog communities)

The pass is not a build mode — it augments whatever mode is cached, so Build mode: full says nothing about it. A later refresh catalog full used to drop these tables silently; it now re-runs the pass at the same resolution, so the graph survives a rebuild. To drop back, delete .mxcli/catalog.db and refresh.

SHOW commands

list communities;                                   -- the community_summary listing
list community of Sales.Order;                      -- which community an asset is in
list community members of Sales.Order;              -- its co-clustered assets

Two refactoring journeys

Spaghetti → layered / modular app.

select * from CATALOG.graph_cycles;                       -- tangled documents
select * from CATALOG.graph_module_cycles;                -- tangled modules
select Layer, AssetName from CATALOG.graph_layers
  order by Layer;                                          -- dependency depth
list communities;                                          -- cleaner module groupings

mxcli reports the facts (layer numbers, directed graph_module_dependencies); your team decides what ordering is “correct” and enforces it with a Starlark rule.

Monolith → multi-app (REST / OData / events).

mxcli -p app.mpr -c "refresh catalog communities resolution 0.6"   # candidate apps
-- the contract list a split would require, classified by mechanism
select * from CATALOG.graph_integration_surface order by Edges desc;

Each crossing edge maps to its integration mechanism — associate→OData/shared entity, retrieve→OData read, call→REST, create/change→event/REST write — and generalize crossings are flagged as blockers (inheritance can’t cross an app boundary).

Enforce your own architecture (Starlark)

mxcli ships the facts, not an opinion. Teams enforce their own layering / allowed-dependency / no-cycle / coupling-budget policies via Starlark lint rules, using these builtins (which read the graph tables):

community_of, layer_of, cycles, module_cycles, module_dependencies, centrality, god_nodes, integration_surface, refs_from.

RULE_ID = "ARCH900"
RULE_NAME = "No Payments→Reporting dependency"
DESCRIPTION = "Payments must not depend on Reporting"
CATEGORY = "architecture"
SEVERITY = "error"

def check():
    return [violation(message = "Payments must not depend on Reporting")
            for d in module_dependencies()
            if d.source_module == "Payments" and d.target_module == "Reporting"]

The graph builtins return empty when the community tables aren’t built, so run refresh catalog communities before lint in the same session. See Writing Custom Rules.

Notes

  • Quality depends on edge completeness. The refs graph captures control flow, CRUD, associations, generalization, widget datasources/actions, layout, and flow parameter/return types. References buried inside expressions / XPath constraints (and enum/constant usage) are not yet edges.
  • Betweenness is O(V·E); it is skipped above ~6,000 nodes to keep the refresh fast (PageRank and communities still run).
  • The Leiden implementation is deterministic and matches the reference leidenalg results.

Full-Text Search

The SEARCH command performs full-text search across all strings and source code in a Mendix project. It searches captions, messages, the documentation of project elements (entities, attributes, constants, pages and more), and MDL source.

Syntax

SEARCH '<keyword>';

Examples

-- Search for a keyword
SEARCH 'validation';

-- Search for an entity name
SEARCH 'Customer';

-- Search for error messages
SEARCH 'cannot be empty';

-- Search for specific patterns
SEARCH 'CurrentDateTime';

CLI Usage

# Basic search
mxcli search -p app.mpr "validation"

# Quiet mode (no headers/formatting), suitable for piping
mxcli search -p app.mpr "validation" -q

# Output as names (type<TAB>name per line)
mxcli search -p app.mpr "validation" -q --format names

# Output as JSON array
mxcli search -p app.mpr "validation" --json

# Exit 1 when anything matches (0 when nothing does)
mxcli search -p app.mpr "validation" --fail-on-match

# Leave out Marketplace modules
mxcli search -p app.mpr "validation" --exclude-marketplace

Output Formats

FlagDescription
--format table (default)Human-readable tables of string and source matches
--format namestype<TAB>name per line, suitable for piping
--jsonJSON array of search results

--json is the same flag every query command takes (refs, callers, callees, impact, context, show, describe, -c "select …"). With it, stdout carries the JSON document and nothing else — connection and catalog progress go to stderr, an empty result is [], and an error is a message on stderr with a non-zero exit. --format json is still accepted as a deprecated spelling of --json.

The -q (quiet) flag suppresses headers and formatting, making output suitable for piping to other commands.

Piping Search Results

Search results can be piped to other mxcli commands for powerful workflows:

# Find the first microflow matching "error" and describe it
mxcli search -p app.mpr "error" -q --format names | head -1 | awk '{print $2}' | \
  xargs mxcli describe -p app.mpr microflow

What Gets Searched

SEARCH looks in two indexes, and says which one a hit came from.

Strings (a full catalog, built automatically):

  • Every translatable text: page titles, widget captions and labels, enumeration value captions, button and menu captions, show-message and validation-feedback texts, in every language

  • Log messages and log node names of microflows

  • Page URLs and the paths, resources and operation summaries of published REST services

  • Workflow names, descriptions, task names and activity captions

  • Agents’ system and user prompts, descriptions, and the descriptions of their tools and knowledge bases, with the title a tool call shows the user (ObjectType AGENT; StringContext system_prompt, user_prompt, agent_description, tool_description, tool_display_title, tool_display_description, knowledge_base_description), and a published MCP service’s tool descriptions and titles (PUBLISHED_MCP_SERVICE; mcp_tool_description, mcp_tool_title)

  • Element names: the qualified name is a searchable column too, so SEARCH 'Customer' also returns the rows of elements named Customer that have some indexed text

  • Documentation of project elements. A hit’s ObjectType names the element and its StringContext is documentation:

    Documented elementObjectType
    EntityENTITY
    Attribute (qualified Module.Entity.Attribute)ATTRIBUTE
    AssociationASSOCIATION
    EnumerationENUMERATION
    ConstantCONSTANT
    Page, snippet, layout, building block, page templatePAGE, SNIPPET, LAYOUT, BUILDING_BLOCK, PAGE_TEMPLATE
    Microflow, nanoflow, ruleMICROFLOW, NANOFLOW, RULE
    WorkflowWORKFLOW
    Java action, JavaScript actionJAVA_ACTION, JAVASCRIPT_ACTION
    Microflow parameter, Java action parameter (qualified Module.Flow.Parameter)MICROFLOW_PARAMETER, JAVA_ACTION_PARAMETER
    Consumed and published REST service, business event serviceCONSUMED_REST_SERVICE, PUBLISHED_REST_SERVICE, BUSINESS_EVENT_SERVICE
    Import mapping, export mapping, JSON structure, data transformerIMPORT_MAPPING, EXPORT_MAPPING, JSON_STRUCTURE, DATA_TRANSFORMER
    Scheduled event, task queue, regular expression, image and icon collection, menuSCHEDULED_EVENT, TASK_QUEUE, REGULAR_EXPRESSION, IMAGE_COLLECTION, ICON_COLLECTION, MENU
    Agent, AI model, knowledge base, consumed and published MCP serviceAGENT, AI_MODEL, KNOWLEDGE_BASE, CONSUMED_MCP_SERVICE, PUBLISHED_MCP_SERVICE
    A module’s domain model documentationMODULE, with StringContext domain_model_documentation

    This is the complete set: every document type whose catalog table carries a documentation text. Not covered: the documentation of an enumeration value (Mendix has no such field) and the summary text of a remote service’s contract entities (written by the remote service, not by the project).

MDL source: microflow, nanoflow and page bodies, expressions and the other definitions as MDL text, through the source index. The first search builds that index and says so on stderr. On a large project this takes a while, once. After a model change it is brought up to date incrementally: only the documents that changed, or that name one that did, are described again.

Searching without building the source index

--no-build never builds the source index. That suits a caller that must stay cheap or must not write the catalog cache beyond what it already holds. If the project has a source index, the search is complete. If it has none, only string literals and documentation are searched: a warning on stderr says so, and the command exits 3. A script can therefore never take “No matches found.” for a complete answer.

mxcli search -p app.mpr "SUB_Do" --no-build --json   # exit 3 when the source index is missing

Failing on a match

--fail-on-match turns search into a gate. The results are printed as usual (a JSON array under --json), and the exit status carries the answer:

Exit statusMeaning
0No hit
1At least one hit
2The search could not run: an unknown project, or a query the index rejects (an unbalanced ", say)
3With --no-build and no source index, no hit was found but the MDL source was not searched
# Refuse the change if a placeholder is left anywhere in the project's text
mxcli search -p app.mpr "TODO" --fail-on-match --json

Without the flag a hit exits 0, as before.

Marketplace modules

By default every module is searched, Marketplace modules included, so existing results do not change. --exclude-marketplace leaves out hits inside modules downloaded from the Marketplace, which is what an audit of the app’s own text usually wants: the vendor’s documentation is not yours to edit. A Marketplace module’s own documentation and domain model documentation are left out with the rest of it.

Comparison with Catalog Queries

SEARCH provides quick keyword-based search, while catalog queries (SELECT FROM CATALOG.*) provide structured SQL-based queries with filtering, joining, and aggregation. Use SEARCH for quick discovery and catalog queries for precise analysis.

-- Quick search: find anything mentioning "Customer"
SEARCH 'Customer';

-- Precise query: find entities named Customer with their module
SELECT Name, ModuleName FROM CATALOG.ENTITIES WHERE Name LIKE '%Customer%';

Catalog Queries

The catalog is a SQLite-based query system that provides SQL access to project metadata. It indexes all elements in your Mendix project – entities, microflows, pages, associations, attributes, and more – into queryable tables that support standard SQL syntax.

How It Works

The catalog builds an in-memory SQLite database from the project’s MPR file. This database is cached in .mxcli/catalog.db next to the MPR file for fast subsequent access.

There are two levels of catalog refresh:

CommandWhat It BuildsUse Case
REFRESH CATALOGBasic metadata tables (entities, microflows, pages, etc.)Quick queries about project structure
REFRESH CATALOG FULLMetadata + cross-references + source contentCode navigation, impact analysis, full-text search

Quick Start

-- Build the catalog
REFRESH CATALOG;

-- See what tables are available
LIST CATALOG TABLES;

-- Query entities in a module
SELECT Name, EntityType FROM CATALOG.ENTITIES WHERE ModuleName = 'Sales';

-- Find microflows that reference an entity
SELECT Name, ModuleName FROM CATALOG.MICROFLOWS
WHERE Description LIKE '%Customer%' OR Name LIKE '%Customer%';

Key Features

  • Standard SQL syntax – SELECT, WHERE, JOIN, GROUP BY, ORDER BY, HAVING, UNION
  • Multiple tables – Entities, attributes, associations, microflows, pages, and more
  • Cross-reference data – Available after REFRESH CATALOG FULL
  • Cached for performance – Stored in .mxcli/catalog.db next to the MPR file
  • AI-friendly – Structured data that AI assistants can query programmatically

CLI Usage

# Refresh catalog
mxcli -p app.mpr -c "REFRESH CATALOG"
mxcli -p app.mpr -c "REFRESH CATALOG FULL"

# Query catalog
mxcli -p app.mpr -c "SELECT Name FROM CATALOG.MICROFLOWS LIMIT 10"
mxcli -p app.mpr -c "LIST CATALOG TABLES"

REFRESH CATALOG

The REFRESH CATALOG command builds or rebuilds the catalog index from the current project state. The catalog must be refreshed before running catalog queries or cross-reference navigation commands.

Syntax

-- Basic refresh: builds metadata tables
REFRESH CATALOG;

-- Full refresh: builds metadata + cross-references + source
REFRESH CATALOG FULL;

-- Force rebuild: ignores cached catalog
REFRESH CATALOG FULL FORCE;

Refresh Levels

Basic Refresh

REFRESH CATALOG;

Builds the core metadata tables: entities, attributes, associations, microflows, pages, enumerations, and other element types. This is sufficient for structural queries like “list all entities in a module” or “find pages with a specific URL.”

Full Refresh

REFRESH CATALOG FULL;

Builds everything in the basic refresh plus:

  • Cross-reference data – Which elements reference which other elements
  • Source content – Microflow activity content, expression strings, widget properties
  • Call graph – Caller/callee relationships between elements

This level is required for:

  • LIST CALLERS OF
  • LIST CALLEES OF
  • LIST REFERENCES TO
  • LIST IMPACT OF
  • DESCRIBE CONTEXT OF
  • SEARCH
  • Full-text catalog queries

Force Rebuild

REFRESH CATALOG FULL FORCE;

Ignores the cached .mxcli/catalog.db file and rebuilds from scratch. Use this when the catalog appears stale or corrupt.

CLI Usage

# Quick refresh
mxcli -p app.mpr -c "REFRESH CATALOG"

# Full refresh with cross-references
mxcli -p app.mpr -c "REFRESH CATALOG FULL"

# Force rebuild
mxcli -p app.mpr -c "REFRESH CATALOG FULL FORCE"

Caching

The catalog is cached in .mxcli/catalog.db next to the MPR file. On subsequent refreshes, mxcli checks whether the project has changed and only rebuilds if necessary (unless FORCE is specified).

Progress output

A build prints a line per table (✓ Entities: 111) only when its output is a terminal. Piped, logged or run by an agent, it prints nothing per table while it is quick, and the latest progress line every 15 seconds while it is slow (… building catalog, source 250/300 documents (32s)), so a build of several minutes still shows it is running. Lines that carry information, such as the incremental summary and documents that could not be described, print either way.

-q / --quiet works on every command: it drops the warning banner and the connection and status messages, and sends what catalog progress remains to stderr.

Incremental rebuilds

A rebuild after a save does not start from scratch. The cache records which document every row came from, and the next rebuild of the same level (fast, FULL or FULL SOURCE) starts from the cached catalog and produces again only the rows of:

  • the documents that changed, were added or were deleted (a document moved to another existing folder counts as changed);
  • the documents whose references name one of them, before or after the change. A document’s rows can depend on what it names: what a delete removes depends on the association it retrieved over. A specialization counts as naming what its generalization holds.

The rows of every other document stay as they are, and only the documents being rebuilt are read and decoded. On a 4,753-document project a refresh after one edit takes under a second, against several seconds for a full build. The progress output then shows one line, incremental: N units rebuilt, M carried over, instead of a count per table.

The rebuild is a full one, as before, when:

  • something changes that no per-document pass reads: a folder, a module, the project settings, the security settings or the navigation;
  • the cache was written by another mxcli build or at another level;
  • FORCE is given.

An incremental rebuild produces exactly the rows a full one would. Only the internal row IDs (the Id column of refs and permissions) and the order of rows in an unsorted query can differ.

With the MDL source index, an entity in a document being rebuilt is described again only when its own definition changed, not when another entity of its module did. A change to an association or annotation describes every entity of that module again, and a change to a document that is never described (a Java action, a constant, an image collection) describes everything.

The unit store (MPR v2)

An MPR v2 project keeps each document in its own file under mprcontents/, and a refresh reads all of them. mxcli keeps a compressed copy of those files in .mxcli/cache/unit-contents.db and reads a document from its own file only when the file has changed since mxcli last read it (its size or modification time differs, or the .mpr records another content hash). A refresh after a save therefore opens one or two files instead of thousands. That matters most on Windows, where a virus scanner inspects every file a process opens.

The store is a cache: deleting it costs one slower refresh, and a store that cannot be read or written is ignored. It is about half the size of mprcontents/, and its directory has its own .gitignore, so it is never committed even where .mxcli/ is not ignored.

When to Refresh

  • After opening a project – Run REFRESH CATALOG to enable queries
  • Before cross-reference navigation – Run REFRESH CATALOG FULL to enable CALLERS/CALLEES/IMPACT
  • After making changes – Refresh to update the catalog with new elements
  • If results seem stale – Use REFRESH CATALOG FULL FORCE to force a rebuild

FULL mode populates the analytic tables. Plain REFRESH CATALOG (fast mode) leaves CATALOG.ACTIVITIES, CATALOG.REFS, CATALOG.XPATH_EXPRESSIONS, and CATALOG.WIDGETS empty — a fast-mode query against them warns “requires refresh catalog full”. Run REFRESH CATALOG FULL to fill them (activity rows carry the model GUIDs, cross-reference edges, and XPath expressions).

Available Tables

The catalog provides several tables that can be queried using standard SQL syntax. Use LIST CATALOG TABLES to list all available tables in your catalog.

Discovering Tables

LIST CATALOG TABLES;

Core Tables

CATALOG.ENTITIES

Information about all entities in the project.

ColumnDescription
IdUnique identifier
NameEntity name
ModuleNameModule containing the entity
QualifiedNameFull qualified name (Module.Entity)
EntityTypePersistent, Non-Persistent, View, External
DescriptionDocumentation text
AttributeCountNumber of attributes
AccessRuleCountNumber of access rules defined
SELECT Name, EntityType, AttributeCount
FROM CATALOG.ENTITIES
WHERE ModuleName = 'Sales'
ORDER BY Name;

CATALOG.ATTRIBUTES

Information about entity attributes.

ColumnDescription
IdUnique identifier
NameAttribute name
EntityIdParent entity ID
AttributeTypeString, Integer, Decimal, Boolean, DateTime, etc.
SELECT a.Name, a.AttributeType
FROM CATALOG.ATTRIBUTES a
JOIN CATALOG.ENTITIES e ON a.EntityId = e.Id
WHERE e.QualifiedName = 'Sales.Customer';

CATALOG.MODULES

One row per module, including System and Marketplace modules.

ColumnDescription
IdModule UUID
NameModule name
Source"" for your own modules and System; "Marketplace v1.2.3" for a downloaded module
AppStoreVersionMarketplace version, when downloaded
DescriptionAlways empty: a Mendix module has no documentation property
DomainModelDocumentationThe module’s domain model’s documentation — the module-level text an author can write
SELECT Name FROM CATALOG.MODULES
WHERE Source = '' AND COALESCE(DomainModelDocumentation, '') = '';

CATALOG.FOLDERS

One row per folder, in every module, empty folders included. A document’s Folder column names only the folders that hold a document, so an empty folder appears nowhere else.

ColumnDescription
IdFolder UUID
NameFolder name
ModuleNameModule the folder is in
PathObjects/Order — spelled as a document’s Folder column, so the two join. A folder name that itself holds / is not escaped (LIST FOLDERS prints it as \/)
ParentPathPath of the enclosing folder; '' directly in the module
Depth1 directly in the module
DocumentCountDocuments directly in the folder, not in its subfolders
FolderCountFolders directly in the folder
-- Empty folders in your own modules
SELECT f.ModuleName, f.Path FROM CATALOG.FOLDERS f
JOIN CATALOG.MODULES m ON m.Name = f.ModuleName
WHERE m.Source = '' AND f.DocumentCount = 0 AND f.FolderCount = 0;

CATALOG.ANNOTATIONS

One row per domain model annotation, in every module. A microflow’s annotations are rows of CATALOG.ACTIVITIES (ActivityType = 'Annotation').

ColumnDescription
IdAnnotation UUID
DomainModelIdThe domain model holding it
ModuleNameModule of that domain model
CaptionThe full text, newlines kept
X, Y, WidthPlace and width on the domain model canvas
SELECT ModuleName, Caption FROM CATALOG.ANNOTATIONS WHERE Caption LIKE '%TODO%';

CATALOG.ASSOCIATIONS

Information about entity associations, same-module and cross-module.

ColumnDescription
IdUnique identifier
NameAssociation name
QualifiedNameModule.Association
ModuleNameModule that owns the association
FromEntityFROM entity qualified name (the one that owns the reference)
ToEntityTO entity qualified name; for a cross-module association, the other module’s entity
AssociationTypeReference or ReferenceSet
OwnerDefault or Both
StorageFormatColumn or Table
DescriptionDocumentation
ToDeleteBehaviorDelete behaviour of the TO end — Mendix’s ChildDeleteBehavior, the end MDL’s on delete clause sets: DeleteMeButKeepReferences (default), DeleteMeAndReferences, DeleteMeIfNoReferences
FromDeleteBehaviorDelete behaviour of the FROM end — Mendix’s ParentDeleteBehavior (Studio Pro only)
ToDeleteErrorMessage / FromDeleteErrorMessageMessage shown when a DeleteMeIfNoReferences delete on that end is refused

Mendix’s Parent/Child pointer names are inverted relative to MDL’s FROM/TO: ParentPointer is the FROM entity and ChildPointer the TO entity, so the Child* delete behaviour belongs to the TO end. Mendix always stores both ends, so an explicitly set behaviour is one that is not the default. System module associations have no stored delete behaviour and read as ''.

SELECT QualifiedName, ToDeleteBehavior, FromDeleteBehavior
FROM CATALOG.ASSOCIATIONS
WHERE ToDeleteBehavior <> 'DeleteMeButKeepReferences'
   OR FromDeleteBehavior <> 'DeleteMeButKeepReferences';

CATALOG.MICROFLOWS

Information about microflows, nanoflows and rules (CATALOG.NANOFLOWS is the nanoflow subset).

ColumnDescription
IdUnique identifier
NameMicroflow name
ModuleNameModule containing the microflow
QualifiedNameFull qualified name
FolderFolder path within the module
MicroflowTypeMICROFLOW, NANOFLOW or RULE
ReturnTypeReturn type of the microflow
DescriptionDocumentation text
ParameterCountNumber of parameters
ActivityCountActivities at the top level of the flow, excluding start/end events and merges. A loop counts as one
TotalActivityCountActivityCount plus every activity inside a loop, at any depth
ComplexityMcCabe cyclomatic complexity
SELECT Name, ReturnType, ActivityCount, TotalActivityCount
FROM CATALOG.MICROFLOWS
WHERE ModuleName = 'Sales'
ORDER BY Name;

CATALOG.ACTIVITIES

One row per object in a microflow, nanoflow or rule body — activities, splits, merges, events, loops and annotations. Populated by REFRESH CATALOG FULL.

Loop bodies are included. The objects inside a loop, at any depth, are rows too, with ParentLoopId naming the loop. Earlier releases left them out; a query written then that should keep its old result filters on ParentLoopId = ''.

ColumnDescription
IdThe object’s ID
MicroflowId, MicroflowQualifiedName, ModuleNameThe flow it belongs to
SequencePre-order position in the flow: a loop, then its body, then the loop’s next sibling
ParentLoopIdId of the enclosing loop; empty at the top level
LoopDepthNumber of enclosing loops; 0 at the top level
ActivityTypeActionActivity, ExclusiveSplit, InheritanceSplit, ExclusiveMerge, LoopedActivity, Annotation, StartEvent, EndEvent, …
ActionTypeThe action of an ActionActivity, e.g. RetrieveAction, JavaActionCallAction, WebServiceCallAction
NameActionType for an action activity, otherwise ActivityType
CaptionThe stored caption: an activity’s, a split’s, or an annotation’s text. Empty for events, merges and loops. With AutoGenerateCaption it holds Studio Pro’s stored placeholder (often Activity)
AutoGenerateCaption1 when Studio Pro generates the activity’s caption
DescriptionDocumentation of an action activity, split or loop
EntityRefEntity of a create object, a database retrieve, or a delete (the deleted variable’s entity, when the flow types it)
ServiceRef, ActionRefCalled service and operation: REST, web service, OData action. For a microflow, nanoflow, Java or JavaScript action call, ActionRef is the called document and ServiceRef is empty. For an import or export action it is the mapping, and for a REST call that uses mappings the response (import) mapping, else the request one; every mapping is a mapping_call row in refs
QueueRefTask queue a microflow or Java action call runs in; empty when it runs in place
UseRequestTimeout, TimeoutExpression“Use a timeout” and its seconds, for a REST or web service call
ConditionExpressionAn exclusive split’s expression
ConditionRuleThe rule a rule-based split calls
ErrorHandlingTypeRollback, Custom, CustomWithoutRollBack (capital B), Continue or Abort — read from the action for an action activity
LogLevel, LogNodeExpression, LogMessageA log message action’s level, node expression ('MyNode' or getKey(…)) and template
CommitTypeCreate/change object: Yes, YesWithoutEvents or No
WithEvents1 for a commit with events, and a create/change with CommitType Yes
RetrieveSourcedatabase or association
X, YThe object’s stored middle point on the canvas, as describe prints it in @position. Y grows downwards. Inside a loop it is relative to the loop’s box, not to the canvas
Width, HeightThe object’s box size
-- Database retrieves inside a loop (N+1 queries)
SELECT MicroflowQualifiedName, EntityRef, LoopDepth
FROM CATALOG.ACTIVITIES
WHERE ActionType = 'RetrieveAction' AND RetrieveSource = 'database' AND ParentLoopId <> '';

-- Microflows called from inside a loop, synchronously (a queued call runs outside it)
SELECT MicroflowQualifiedName, ActionRef
FROM CATALOG.ACTIVITIES
WHERE ActionType = 'MicroflowCallAction' AND ParentLoopId <> '' AND QueueRef = '';

CATALOG.FLOWS

One row per sequence flow and annotation flow of a microflow, nanoflow or rule, loop bodies included. Populated by REFRESH CATALOG FULL. Both ends are CATALOG.ACTIVITIES.Id values.

ColumnDescription
IdThe flow’s ID
FlowTypeSequenceFlow or AnnotationFlow
MicroflowId, MicroflowQualifiedName, ModuleNameThe flow document it belongs to
SequenceStored order: each collection’s sequence flows, then its annotation flows
OriginId, DestinationIdThe activity the line leaves and enters. An annotation flow runs from the annotation to the object it is attached to
IsErrorHandler1 for the flow into an activity’s custom error handler (on error … begin … end error in describe)
CaseValueThe branch a split’s flow takes: true / false, an enumeration value or (empty), or an inheritance split’s entity. Empty for an unconditional flow
OriginConnectionIndex, DestinationConnectionIndexThe side the line leaves and enters: 0 top, 1 right, 2 bottom, 3 left
-- Custom error handlers whose first activity logs below level Error
SELECT f.MicroflowQualifiedName, a.LogLevel
FROM CATALOG.FLOWS f
JOIN CATALOG.ACTIVITIES a ON a.Id = f.DestinationId
WHERE f.IsErrorHandler = 1 AND a.ActionType = 'LogMessageAction' AND a.LogLevel <> 'Error';

-- The activity each annotation is attached to
SELECT f.MicroflowQualifiedName, n.Caption, a.ActionType
FROM CATALOG.FLOWS f
JOIN CATALOG.ACTIVITIES n ON n.Id = f.OriginId
JOIN CATALOG.ACTIVITIES a ON a.Id = f.DestinationId
WHERE f.FlowType = 'AnnotationFlow';

CATALOG.WORKFLOW_ACTIVITIES

One row per activity of a workflow, in walk order: the main flow, each activity followed by the paths of its boundary events, then the event sub-processes. Populated by every catalog build. A slot that names nothing is NULL.

ColumnDescription
IdThe activity’s ID
WorkflowId, WorkflowQualifiedName, ModuleNameThe workflow it belongs to (CATALOG.WORKFLOWS.Id)
SequenceWalk order within the workflow
Name, CaptionAs in Studio Pro
ActivityTypeUserTask, MultiUserTask, SystemTask, CallMicroflow, AIAgentTask, CallWorkflow, Decision, ParallelSplit, JumpTo, WaitForTimer, WaitForNotification, StartWorkflow, EndWorkflow, and the event sub-process and path-end kinds
MicroflowThe microflow a system task, call-microflow activity or AI agent task runs
UserSourceMicroflowA user task’s targeting microflow (users or groups)
OnCreatedMicroflowA user task’s on-created microflow
CompletionMicroflowA multi-user task’s completion microflow
Page, UserTaskEntityA user task’s page and task entity
CalledWorkflowThe workflow a call-workflow activity calls

CATALOG.REFS records that a workflow calls a microflow; this table says from which kind of activity and which slot.

-- Microflows that system tasks run, without the WF_SYS_ prefix
SELECT WorkflowQualifiedName, Name, Microflow
FROM CATALOG.WORKFLOW_ACTIVITIES
WHERE ActivityType = 'SystemTask' AND Microflow NOT LIKE '%.WF_SYS_%';

CATALOG.PAGES

Information about pages and their properties.

ColumnDescription
IdUnique identifier
NamePage name
QualifiedNameFull qualified name
ModuleNameModule containing the page
FolderFolder path within the module
TitlePage title in the project’s default language (else en_US, else the lowest-sorted non-empty language). Every translation is in CATALOG.STRINGS (StringContext = 'Forms$Page.Title')
URLPage URL if configured
LayoutRefQualified name of the layout (full build only)
DescriptionDocumentation text
ParameterCountNumber of page parameters
WidgetCountNumber of widgets (full build only; 0 otherwise)
Excluded1 when the page is excluded from the project
Class, Style, DynamicClassesThe page’s own Appearance: CSS class, inline style and dynamic-classes expression (full build only)
SELECT Name, Title, URL
FROM CATALOG.PAGES
WHERE ModuleName = 'Sales'
ORDER BY Name;

Page templates are not pages and are not in this table — see CATALOG.PAGE_TEMPLATES.

CATALOG.SNIPPETS

Snippets (Forms$Snippet).

ColumnDescription
IdUnique identifier
NameSnippet name
QualifiedNameFull qualified name
ModuleNameModule containing the snippet
FolderFolder path within the module
DescriptionDocumentation text
ParameterCountNumber of snippet parameters
WidgetCountNumber of widgets (full build only; 0 otherwise)
Excluded1 when the snippet is excluded from the project
-- UI documents excluded from the project, for cleanup
SELECT 'PAGE' AS Kind, QualifiedName FROM CATALOG.PAGES WHERE Excluded
UNION ALL SELECT 'SNIPPET', QualifiedName FROM CATALOG.SNIPPETS WHERE Excluded
UNION ALL SELECT 'LAYOUT', QualifiedName FROM CATALOG.LAYOUTS WHERE Excluded;

CATALOG.WIDGETS

One row per widget instance on a page or snippet. Full build only (refresh catalog full); a fast build leaves the table empty.

ColumnDescription
IdThe widget’s element ID
NameWidget name
WidgetTypeStorage type (Forms$DataView, Forms$ActionButton, …); a pluggable widget’s id (com.mendix.widget.web.datagrid.Datagrid)
ContainerId, ContainerQualifiedName, ContainerTypeThe page, snippet or layout holding the widget; ContainerType is PAGE, SNIPPET or LAYOUT. UPDATE WIDGETS changes page and snippet widgets only
ModuleName, FolderThe container’s module and folder
EntityRef, AttributeRef, MicroflowRef, NanoflowRef, PageRef, SnippetRefWhat the widget’s own content references: datasource entity, bound attribute, action or datasource flow, the page its action opens, the snippet a snippet call places
ParentWidgetIdId of the nearest widget in this table that encloses it; empty at the page or snippet root. What the catalog skips is transparent: the synthetic conditionalVisibilityWidget… container, layout grid rows and columns, tab pages, a pluggable widget’s properties and object-list items. A widget in a layout grid column or a data grid 2 column has the grid as its parent
DepthNumber of ancestors in this table: 0 at the root. A list view template is a row of its own, so its widgets are two below the list view. A snippet call is not entered: a snippet’s widgets have their own rows, depth 0 at the snippet root
Class, Style, DynamicClassesThe widget’s Appearance
ActionTypeStored type of the primary action — Action (buttons), else OnClickAction (containers), else ClickAction (list views, images): Forms$DeleteClientAction, Forms$MicroflowAction, Forms$CallNanoflowClientAction, Forms$FormAction (show page), Forms$NoAction, …; empty for a widget without one. A pluggable widget’s actions are not read
HasConfirmation1 when the primary action asks for confirmation. Only microflow, nanoflow and workflow calls have that setting; a delete action never does
CaptionThe widget’s own caption in the project’s default language (buttons and labels; an input’s label and a dynamic text’s content are not its caption); '' when it has none
TooltipThe widget’s tooltip in the project’s default language; '' when none is set
-- Widgets nested more than five deep, deepest first
SELECT ContainerQualifiedName, Name, WidgetType, Depth
FROM CATALOG.WIDGETS
WHERE Depth > 5
ORDER BY Depth DESC;

-- Buttons that delete without going through a flow
SELECT ContainerQualifiedName, Name
FROM CATALOG.WIDGETS
WHERE ActionType = 'Forms$DeleteClientAction';

CATALOG.PAGE_TEMPLATES

The starting points Studio Pro’s “new page” dialog offers (Forms$PageTemplate). A separate document type from pages: Atlas_Web_Content ships 46 templates and no pages at all.

ColumnDescription
IdUnique identifier
NameTemplate name
ModuleNameModule containing the template
QualifiedNameFull qualified name
FolderFolder path within the module
DescriptionDocumentation

There is no DESCRIBE PAGE TEMPLATE, so a template is indexed but not describable — tools that walk a module report it as unknown, never as unchanged.

CATALOG.LAYOUTS

Page layouts (Forms$Layout), including those in Marketplace modules such as Atlas_Core.

ColumnDescription
IdUnique identifier
NameLayout name
QualifiedNameFull qualified name
ModuleNameModule containing the layout
FolderFolder path within the module
LayoutTypeResponsive, Phone, Tablet, ModalPopup (web); Default, Popup (native)
PlatformWeb or Native — the platform every page on this layout renders on. React-client errors such as CE0582 apply to Web pages only
DescriptionDocumentation text
Excluded1 when the layout is excluded from the project
Class, Style, DynamicClassesThe layout’s own Appearance (full build only). Its widgets are in CATALOG.WIDGETS with ContainerType = 'LAYOUT'
-- pages on a native layout (needs a full build for LayoutRef)
SELECT p.QualifiedName
FROM CATALOG.PAGES p JOIN CATALOG.LAYOUTS l ON l.QualifiedName = p.LayoutRef
WHERE l.Platform = 'Native';

CATALOG.ACCESS_RULES

Information about entity access rules (available after full refresh).

ColumnDescription
IdUnique identifier
EntityIdEntity this rule applies to
UserRoleRole this rule grants access to
AllowReadWhether read access is granted
AllowWriteWhether write access is granted
SELECT e.QualifiedName, ar.UserRole, ar.AllowRead, ar.AllowWrite
FROM CATALOG.ACCESS_RULES ar
JOIN CATALOG.ENTITIES e ON ar.EntityId = e.Id
WHERE e.ModuleName = 'Sales';

CATALOG.SCHEDULED_EVENTS

Scheduled events — Mendix’s cron.

ColumnDescription
Name, QualifiedName, ModuleName, FolderIdentity
MicroflowQualified name of the microflow the event runs
RepeatSchedule variant: Minute, Hour, Day, Week, MonthDate, MonthWeekday, YearDate, YearWeekday
RepeatDescriptionThe schedule as a phrase, e.g. weekly Mon/Fri at 09:30
IntervalSecondsGap between runs, derived from the schedule
Enabled1 if the event runs
TimeZoneUTC or Server
OnOverlapDelayNext or SkipNext

IntervalSeconds comes from the schedule, not from the stored Interval/IntervalType pair. Those are a legacy sibling that Studio Pro writes and does not keep in sync — a shipped Mendix module stores 0/Minute beside a daily schedule — so a query keyed on them would read a nightly job as firing every 0 seconds. Month and year figures are averages (30 and 365 days): the column is for thresholds and ordering, not calendar arithmetic.

-- Anything that fires more often than once a minute
select QualifiedName, RepeatDescription, Microflow
from CATALOG.SCHEDULED_EVENTS
where Enabled = 1 and IntervalSeconds < 60;

-- Scheduled events whose microflow no longer exists
select se.QualifiedName, se.Microflow
from CATALOG.SCHEDULED_EVENTS se
left join CATALOG.MICROFLOWS m on m.QualifiedName = se.Microflow
where m.Id is null;

A scheduled event also produces a schedule row in CATALOG.REFS, so list callers of <microflow> and the dead-asset analysis both see it. Without that edge a microflow run only by a scheduled event looked unreferenced.

CATALOG.TASK_QUEUES

Task queues. (CATALOG.QUEUES is the old name, still accepted.)

ColumnDescription
Name, QualifiedName, ModuleName, FolderIdentity
ParallelismHow many tasks run at once — an expression string, not a number
ClusterWide1 if the limit applies across the cluster

Parallelism is stored as text because Mendix stores an expression: a query must not assume it parses as an integer.

select QualifiedName, Parallelism, ClusterWide from CATALOG.TASK_QUEUES;

Offline synchronization

CATALOG.OFFLINE_ENTITY_CONFIGS — one row per entity an offline navigation profile synchronizes.

select ProfileName, EntityQualifiedName, SyncMode, XPathConstraint
  from CATALOG.OFFLINE_ENTITY_CONFIGS
 where SyncMode = 'All';

CATALOG.NAVIGATION_PROFILES.OfflineEntityCount says how many and nothing else; this table is what makes “which entities does this profile sync, and how?” answerable — the first question anyone auditing an offline app asks.

SyncMode is the value Mendix stores, not the caption Studio Pro shows: All, Constrained, Never, None, NoneAndPreserveData, Online. Its dialog’s “All Objects” is All and “By XPath” is Constrained.

CompatibilityMode is indexed although MDL cannot author it. The catalog reports what is stored, and a flag invisible to every query is one nobody discovers until it matters.

A configured entity also produces a sync row in CATALOG.REFS, so list references to Sales.Order names the profiles that download it:

select SourceName, TargetName from CATALOG.REFS where RefKind = 'sync';

Every mode gets an edge, including the ones that download nothing. A profile with sync Sales.Audit never still names that entity, so renaming or dropping it leaves the configuration dangling — which is precisely what a reference edge exists to reveal.

Entity event handlers

CATALOG.ENTITY_EVENT_HANDLERS — one row per entity event handler: which moment, which event, and which microflow runs.

select EntityQualifiedName, Moment, Event, Microflow
  from CATALOG.ENTITY_EVENT_HANDLERS
 where Moment = 'Before' and Event = 'Commit';

CATALOG.ENTITIES.HasEventHandlers says some exist and nothing else — the same shape NavigationProfile.OfflineEntityCount had. The distinction the flag loses is the one that matters: a Before handler with RaiseErrorOnFalse can veto the commit, an After handler cannot.

Moment is Before or After. Event is the value Mendix stores, not the caption Studio Pro shows: Create, Commit, Delete, RollBack — note the capital B, which generated/metamodel confirms (DomainModelsEventRollBack = "RollBack") and which disagrees with every neighbouring enum in that file, where the same word is Rollback. A query spelling it the expected way returns zero rows rather than an error.

A handler also produces an event row in CATALOG.REFS, so a microflow that runs only as a handler has callers:

select SourceName, TargetName from CATALOG.REFS where RefKind = 'event';

Without that edge the handler microflow was reported dead from three directions at once — list callers said (no callers found), CATALOG.GRAPH_DEAD_ASSETS listed it, and mxcli lint emitted QUAL004 “is not called from anywhere” with the suggestion Remove if unused — on code that runs on every commit (mendixlabs/mxcli#1127).

The edge carries neither the moment nor the event; refs has no column for them, and a kind per combination would put eight kinds into every consumer’s list to say one thing. Which moment and which event is this table’s question.

Agent Tables

The Agent Editor’s documents, with the settings their JSON contents hold (ako/mxcli#2075). A boolean that a document does not store, as an older Agent Editor does not, is NULL rather than 0, so a query can tell “off” from “never set”.

Absent is NULL. Every reference column (Model, Entity, Document, KnowledgeBase, KeyConstant, Endpoint, AuthenticationMicroflow) and every optional number or switch is NULL when there is none, so “no endpoint” and “no selected model” are both IS NULL. A plain text setting (ToolChoice, ToolType, UserAccessApproval) is the stored text, '' when the document stores none (ako/mxcli#2093).

CATALOG.AGENTS

ColumnDescription
UsageTypeChat or Task
ModelThe AI model document, qualified name (NULL if none)
SelectedModel, SelectedModelNameThe model picked from the provider’s list, by id and display name (NULL if none)
ToolChoice, MaxTokens, Temperature, TopPGeneration settings: MaxTokens, Temperature and TopP are NULL when not set, ToolChoice is ''
EnableBackendStreaming, EnableUIStreaming1, 0, or NULL when the document does not store the switch
EntityThe context entity, qualified name (NULL if none)
VariablesThe prompt variable keys, comma-separated
ToolCount, KnowledgeBaseCount, MCPServiceCountEntries in the agent’s tool lists. ToolCount excludes MCP services

CATALOG.AGENT_TOOLS

One row per entry of an agent’s tools list that ToolCount counts: a microflow, nanoflow or agent it calls. Its consumed MCP services are in CATALOG.AGENT_MCP_SERVICES, as MCPServiceCount counts them.

ColumnDescription
AgentId, AgentThe agent’s id and qualified name
Name, DescriptionWhat the model sees
ToolTypeMicroflow, or empty
DocumentThe microflow, nanoflow or agent, qualified name
Enabled1 or 0
UserAccessApprovalHiddenForUser, VisibleForUser, UserConfirmationRequired, or empty when not stored
DisplayTitle, DisplayDescriptionWhat the user sees of the call, NULL when not stored

CATALOG.AGENT_MCP_SERVICES has the same columns for the agent’s MCP services, with Document named Service and no ToolType.

-- Tools an agent can run without the user's approval
SELECT Agent, Name, Document FROM CATALOG.AGENT_TOOLS
WHERE Enabled = 1 AND UserAccessApproval <> 'UserConfirmationRequired';

CATALOG.AGENT_KNOWLEDGE_BASES

One row per entry of an agent’s knowledge base tools list: Agent, Name, Description, KnowledgeBase (qualified name), Collection, MaxResults, MinSimilarity, IsVisible and Enabled.

Models, knowledge bases and MCP services

TableColumns beyond the document’s
CATALOG.AI_MODELSProvider, ModelType, DisplayName, KeyConstant
CATALOG.KNOWLEDGE_BASESProvider, KeyConstant
CATALOG.CONSUMED_MCP_SERVICESProtocolVersion, Version, ConnectionTimeoutSeconds, Endpoint (the String constant holding the server URL, NULL when none: the service connects nowhere), AuthenticationMicroflow
CATALOG.PUBLISHED_MCP_SERVICESServerName, EndpointPath, Version, ProtocolVersion, AuthenticationEnabled, AuthenticationMicroflow, ToolCount

What an agent references (its tools, model, knowledge bases and context entity), what a consumed MCP service reads and calls (its endpoint constant and credentials microflow) and what a published MCP service runs are also edges in CATALOG.REFS, so show callers and show impact see them.

Graph-Analysis Tables

The dependency graph (CATALOG.REFS, full refresh) is analysed by a family of graph_* views and tables — god nodes, module coupling/cohesion, dead documents, communities, cycles, layers, centrality, and the integration surface. The community/cycle/layer/centrality tables are populated by REFRESH CATALOG COMMUNITIES — a pass, not a build mode, so a full-mode catalog has them empty until it runs and a query against one says so rather than returning 0 rows. See Graph Analysis for the full reference and the mxcli graph-report command.

Cycles come in two flavours, because they are two questions: CATALOG.GRAPH_CYCLES for tangled documents and CATALOG.GRAPH_MODULE_CYCLES for tangled modules — the second is not a rollup of the first, since modules reference each other through documents that need form no cycle themselves. CATALOG.GRAPH_ANALYSIS_SCOPE reports which reference kinds reach the computed asset graph, which is what to check when a graph_module_coupling edge seems to have no effect on cycles or layers.

select * from CATALOG.graph_god_nodes order by Degree desc limit 20;
select * from CATALOG.graph_module_coupling order by Edges desc;
select * from CATALOG.graph_module_cycles order by CycleSize desc;
list communities;

Listing All Tables

To see the complete list of available tables in your catalog (which may vary by project and refresh level):

LIST CATALOG TABLES;
mxcli -p app.mpr -c "LIST CATALOG TABLES"

Renamed tables

The integration and queue tables follow Studio Pro’s document names. The old names still answer as aliases, but LIST CATALOG TABLES shows only the new ones, and CATALOG.OBJECTS reports the new ObjectType values:

TableOld nameObjectType (old)
CATALOG.CONSUMED_REST_SERVICESREST_CLIENTSCONSUMED_REST_SERVICE (REST_CLIENT)
CATALOG.CONSUMED_REST_OPERATIONSREST_OPERATIONS—
CATALOG.CONSUMED_ODATA_SERVICESODATA_CLIENTSCONSUMED_ODATA_SERVICE (ODATA_CLIENT)
CATALOG.PUBLISHED_ODATA_SERVICESODATA_SERVICESPUBLISHED_ODATA_SERVICE (ODATA_SERVICE)
CATALOG.TASK_QUEUESQUEUESTASK_QUEUE (QUEUE)

A query that filters OBJECTS on an old ObjectType value returns no rows; change it to the new value.

SQL Queries

The catalog supports standard SQL SELECT syntax for querying project metadata. Queries use the CATALOG. prefix to identify catalog tables.

Syntax

SELECT <columns> FROM CATALOG.<table> [WHERE <condition>] [ORDER BY <column>] [LIMIT <n>]

Standard SQL features are supported: SELECT, WHERE, JOIN, GROUP BY, ORDER BY, HAVING, UNION, LIMIT, aggregate functions (COUNT, SUM, AVG, MIN, MAX), and subqueries.

Basic Queries

Listing Elements

-- All entities in a module
SELECT Name, EntityType, Description
FROM CATALOG.ENTITIES
WHERE ModuleName = 'Sales';

-- All microflows in a module
SELECT Name, ReturnType
FROM CATALOG.MICROFLOWS
WHERE ModuleName = 'Sales';

-- Pages with URLs
SELECT Name, ModuleName, URL
FROM CATALOG.PAGES
WHERE URL IS NOT NULL;

Filtering with LIKE

-- Find entities by name pattern
SELECT QualifiedName FROM CATALOG.ENTITIES
WHERE Name LIKE '%Customer%';

-- Find microflows referencing an entity
SELECT Name, ModuleName
FROM CATALOG.MICROFLOWS
WHERE Description LIKE '%Customer%'
   OR Name LIKE '%Customer%';

-- Find pages by URL pattern
SELECT Name, ModuleName, URL
FROM CATALOG.PAGES
WHERE URL LIKE '/admin%';

Joining Tables

-- Attributes of a specific entity
SELECT a.Name, a.AttributeType
FROM CATALOG.ATTRIBUTES a
JOIN CATALOG.ENTITIES e ON a.EntityId = e.Id
WHERE e.QualifiedName = 'Sales.Customer';

-- Entity relationships
SELECT Name, ParentEntity, ChildEntity, AssociationType
FROM CATALOG.ASSOCIATIONS
WHERE ParentEntity LIKE '%Order%'
   OR ChildEntity LIKE '%Order%';

Aggregation

-- Count entities per module
SELECT ModuleName, COUNT(*) AS EntityCount
FROM CATALOG.ENTITIES
GROUP BY ModuleName
ORDER BY EntityCount DESC;

-- Documentation coverage by module
SELECT
    ModuleName,
    COUNT(*) AS TotalEntities,
    SUM(CASE WHEN Description != '' THEN 1 ELSE 0 END) AS Documented,
    ROUND(100.0 * SUM(CASE WHEN Description != '' THEN 1 ELSE 0 END) / COUNT(*), 1) AS CoveragePercent
FROM CATALOG.ENTITIES
GROUP BY ModuleName
ORDER BY CoveragePercent ASC;

Subqueries and UNION

-- Complete data flow for an entity
SELECT 'Page' AS Source, p.QualifiedName AS Element, 'displays' AS Action
FROM CATALOG.PAGES p
WHERE p.DataSource LIKE '%Customer%'
UNION ALL
SELECT 'Microflow' AS Source, m.QualifiedName AS Element, 'processes' AS Action
FROM CATALOG.MICROFLOWS m
WHERE m.ObjectUsage LIKE '%Customer%';

CLI Usage

# Run a catalog query
mxcli -p app.mpr -c "SELECT Name FROM CATALOG.ENTITIES WHERE ModuleName = 'Sales'"

# Query with limit
mxcli -p app.mpr -c "SELECT Name FROM CATALOG.MICROFLOWS LIMIT 10"

Notes

  • Always run REFRESH CATALOG (or REFRESH CATALOG FULL) before querying
  • Table and column names are case-sensitive in some contexts
  • String comparisons use SQLite semantics (case-sensitive by default with =, use LIKE for case-insensitive matching)
  • The catalog is read-only; INSERT, UPDATE, and DELETE are not supported

Use Cases

This page provides practical catalog query examples for common analysis tasks: impact analysis, finding unused elements, detecting anti-patterns, and computing complexity metrics.

Duplicate Entity Detection

Find entities with similar names across modules that may be candidates for consolidation:

SELECT
    e1.ModuleName AS Module1,
    e1.Name AS Entity1,
    e2.ModuleName AS Module2,
    e2.Name AS Entity2
FROM CATALOG.ENTITIES e1
JOIN CATALOG.ENTITIES e2
    ON LOWER(e1.Name) = LOWER(e2.Name)
    AND e1.ModuleName < e2.ModuleName
ORDER BY e1.Name;

Detect copy-paste inheritance anti-patterns by finding entities with identical attribute patterns:

SELECT
    e1.QualifiedName AS Entity1,
    e2.QualifiedName AS Entity2,
    COUNT(*) AS MatchingAttributes
FROM CATALOG.ATTRIBUTES a1
JOIN CATALOG.ATTRIBUTES a2
    ON a1.Name = a2.Name
    AND a1.AttributeType = a2.AttributeType
    AND a1.EntityId != a2.EntityId
JOIN CATALOG.ENTITIES e1 ON a1.EntityId = e1.Id
JOIN CATALOG.ENTITIES e2 ON a2.EntityId = e2.Id
WHERE e1.QualifiedName < e2.QualifiedName
GROUP BY e1.QualifiedName, e2.QualifiedName
HAVING COUNT(*) >= 3
ORDER BY MatchingAttributes DESC;

Data Lineage Analysis

Entity Dependency Graph

Find all entities connected to a given entity via associations:

SELECT
    'Outgoing' AS Direction,
    a.Name AS Association,
    a.ChildEntity AS RelatedEntity,
    a.AssociationType AS Type
FROM CATALOG.ASSOCIATIONS a
WHERE a.ParentEntity = 'Sales.Order'
UNION ALL
SELECT
    'Incoming' AS Direction,
    a.Name AS Association,
    a.ParentEntity AS RelatedEntity,
    a.AssociationType AS Type
FROM CATALOG.ASSOCIATIONS a
WHERE a.ChildEntity = 'Sales.Order';

Microflows That Modify an Entity

Track all business logic touching specific data:

SELECT
    m.QualifiedName AS Microflow,
    m.ReturnType,
    m.Description
FROM CATALOG.MICROFLOWS m
WHERE m.ObjectUsage LIKE '%Sales.Customer%'
   OR m.Parameters LIKE '%Sales.Customer%'
   OR m.ReturnType LIKE '%Sales.Customer%';

Page-to-Entity Mapping

Which pages display or edit which entities:

SELECT
    p.QualifiedName AS Page,
    p.DataSource AS Entity,
    p.WidgetTypes
FROM CATALOG.PAGES p
WHERE p.DataSource IS NOT NULL
ORDER BY p.DataSource, p.QualifiedName;

Orphan Detection

Find persistent entities with no associations, which may indicate missing relationships:

SELECT e.QualifiedName, e.EntityType, e.AttributeCount
FROM CATALOG.ENTITIES e
LEFT JOIN CATALOG.ASSOCIATIONS a
    ON e.QualifiedName = a.ParentEntity
    OR e.QualifiedName = a.ChildEntity
WHERE a.Id IS NULL
  AND e.EntityType = 'Persistent'
ORDER BY e.ModuleName, e.Name;

Complexity Analysis

Find entities with high complexity (many attributes and associations):

SELECT
    e.QualifiedName,
    e.AttributeCount,
    COUNT(a.Id) AS AssociationCount,
    e.AttributeCount + COUNT(a.Id) AS ComplexityScore
FROM CATALOG.ENTITIES e
LEFT JOIN CATALOG.ASSOCIATIONS a
    ON e.QualifiedName = a.ParentEntity
    OR e.QualifiedName = a.ChildEntity
GROUP BY e.QualifiedName, e.AttributeCount
HAVING ComplexityScore > 15
ORDER BY ComplexityScore DESC;

Documentation Coverage

Identify technical debt in documentation:

-- Entities without documentation
SELECT QualifiedName, EntityType, AttributeCount
FROM CATALOG.ENTITIES
WHERE (Description IS NULL OR Description = '')
ORDER BY AttributeCount DESC;

-- Documentation coverage by module
SELECT
    ModuleName,
    COUNT(*) AS TotalEntities,
    SUM(CASE WHEN Description != '' THEN 1 ELSE 0 END) AS Documented,
    ROUND(100.0 * SUM(CASE WHEN Description != '' THEN 1 ELSE 0 END) / COUNT(*), 1) AS CoveragePercent
FROM CATALOG.ENTITIES
GROUP BY ModuleName
ORDER BY CoveragePercent ASC;

Security Analysis

Entities Without Access Rules

SELECT
    e.QualifiedName,
    e.EntityType,
    e.AccessRuleCount
FROM CATALOG.ENTITIES e
WHERE e.AccessRuleCount = 0
  AND e.EntityType = 'Persistent'
ORDER BY e.ModuleName;

Wide-Open Entities

Review entities accessible to anonymous users:

SELECT
    e.QualifiedName,
    e.AttributeCount,
    ar.UserRole,
    ar.AllowRead,
    ar.AllowWrite
FROM CATALOG.ENTITIES e
JOIN CATALOG.ACCESS_RULES ar ON e.Id = ar.EntityId
WHERE ar.AllowRead = 1
  AND ar.UserRole = 'Anonymous'
ORDER BY e.AttributeCount DESC;

Module Health Dashboard

Overview of module complexity and documentation:

SELECT
    ModuleName,
    COUNT(DISTINCT e.Id) AS Entities,
    COUNT(DISTINCT m.Id) AS Microflows,
    COUNT(DISTINCT p.Id) AS Pages,
    ROUND(100.0 * SUM(CASE WHEN e.Description != '' THEN 1 ELSE 0 END) /
        NULLIF(COUNT(DISTINCT e.Id), 0), 1) AS DocCoverage
FROM CATALOG.ENTITIES e
LEFT JOIN CATALOG.MICROFLOWS m ON e.ModuleName = m.ModuleName
LEFT JOIN CATALOG.PAGES p ON e.ModuleName = p.ModuleName
GROUP BY ModuleName
ORDER BY Entities DESC;

Naming Convention Violations

Find entities not following PascalCase:

SELECT QualifiedName, Name
FROM CATALOG.ENTITIES
WHERE Name != UPPER(SUBSTR(Name, 1, 1)) || SUBSTR(Name, 2)
   OR Name LIKE '%_%'
   OR Name LIKE '% %';

Query the catalog directly (and join it to other sources)

The catalog is a SQLite database — <projectDir>/.mxcli/catalog.db, written next to the .mpr and refreshed by refresh catalog. You do not need to export it to JSON (that drags in human-readable formatting, exposes only a subset of the tables, and goes stale immediately). Attach the file directly with any SQLite- or DuckDB-compatible tool for ad-hoc analysis beyond what SELECT … FROM CATALOG.* exposes in one query.

Run refresh catalog full first for the analytic tables. Plain refresh catalog (fast mode) leaves activities / refs / xpath_expressions (and widgets) empty — a fast-mode query against them warns “requires refresh catalog full”. Full mode populates them (activities carry the model GUIDs the debugger uses, one row per activity with its sequence).

# from a dev container, after: mxcli -p app.mpr -c "refresh catalog full"
duckdb -c "ATTACH '.mxcli/catalog.db' AS cat (TYPE sqlite, READ_ONLY);
           SELECT MicroflowQualifiedName, COUNT(*) AS activities
           FROM cat.activities_data GROUP BY 1 ORDER BY 2 DESC LIMIT 10;"

Cross-source “app warehouse”

Because the catalog is a plain database, one engine (e.g. DuckDB — mxcli does not embed it) can ATTACH it alongside the app’s dev Postgres read-only and a trace dump, joining model shape + live data + telemetry with no ETL to answer questions no single source can (runtime cost × model shape, query time × entity × live rows). The full logging/metrics/tracing/catalog analysis procedure — including this join — is the analyze-runtime skill (mxcli init installs it into your project’s .claude/skills/mendix/).

Project Brain

mxcli brain records the project knowledge mxcli cannot compute. Two halves:

  • Decisions — why a pattern was chosen here, which marketplace version broke what, what a recurring mxbuild error means in this app.
  • Open questions — what is not decided yet, recorded so it is not silently forgotten and rediscovered expensively later.
  • The plan — the requirements being built from, grouped into slices, when the source is a specification document, a prototype or a conversation.

The plan half exists because that source leaves no trace in git. A Word document, a Figma file and a chat window are not an issue and not a commit message, so hours of work can end with nothing recording what it was for — and a session resuming tomorrow has no idea what it was building towards.

It is opt-in. A project without docs/brain/ never hears about it. mxcli’s bootstrap interview asks for requirements by default.

The rule

Anything mxcli can answer does not belong in the brain. Entities, microflows, pages, bindings, references and callers are all queryable. A note that transcribes any of them will disagree with the project the moment someone edits the model — and it will disagree silently, because nothing checks prose.

The brain stores only the negative space: the reason, the constraint, the history that no query can reach.

Layout

docs/brain/
  README.md              what the folder is
  project.md             cross-cutting decisions
  modules/Sales.md       decisions anchored to the Sales module
  modules/Finance.md     …
  plan/01-accounts.md    requirements for one deliverable slice
  plan/02-approvals.md   …

Committed, and reviewed in a pull request like any other change.

A shard is re-rendered from its entries on every write, so the title and the preamble are regenerated and hand-edits to them do not survive. YAML frontmatter is preserved, because mxcli does not own it — it is where markdown tooling keeps per-file metadata (Foam and Obsidian tags, a docs site’s nav weight), and discarding it would quietly break any of those. It counts toward the shard’s cap, since a session loading the shard loads it too. A shard with no frontmatter never grows an empty block.

The split is not cosmetic. A single file would make the size cap a project-wide budget — recording a Sales decision would compete with a Finance one — and every session would load every module’s decisions. With one file per module, a session loads project.md plus the shards for the modules it is touching.

mxcli brain brief assembles that set, so the saving does not depend on anyone judging it correctly:

mxcli brain brief --slice 07-planning -p app.mpr   # project + that slice's
                                                   # modules + its plan
mxcli brain brief --module Sales -p app.mpr        # project + Sales, no plan

Which modules a slice needs is derived from its requirements’ anchors — you do not name them, because that is the thing the brief is being read to find out. The pack goes to stdout and its size to stderr, so it pipes straight into a prompt; --json returns the shards separately with their paths.

This is worth little in one long session, where the store is read once and then cached. It is worth a large fraction of the context when each slice runs in its own session or sub-agent and the pack is re-read from a cold start every time.

Anchors

An entry’s anchors are what make it routable and checkable.

AnchorNames
@Salesa module
@Sales.Ordera document: entity, microflow, page, workflow, …
@Sales.Order.Statusa member: an attribute

The first anchor decides the file: @Sales.Order puts the entry in modules/Sales.md. An entry with no anchor is cross-cutting and goes to project.md.

There is no index to maintain — the module prefix is the file name.

Workflow

An agent captures; a person promotes.

mxcli brain init -p app.mpr

mxcli brain capture "Orders are committed by Finance, not Sales" \
  -a @Sales.Order -a @Finance.ACT_Post -p app.mpr

mxcli brain staged -p app.mpr      # review the queue
mxcli brain promote a1b2c3 -p app.mpr

Captures go to .mxcli/brain/staged.jsonl, which mxcli init git-ignores — so nothing reaches a pull request until someone has looked at it. mxcli lint prints a one-line reminder when the queue is not empty.

The queue is deliberately not sharded: routing it would force the file decision before a human has looked at the entry, which is the decision promotion exists to make.

Checking

mxcli brain check -p app.mpr             # every shard
mxcli brain check --changed -p app.mpr   # only shards this branch touched

Two independent things are checked.

Does each anchor still resolve? Three outcomes, one of which is a failure:

OutcomeMeaningFails
resolvedthe anchor names something that is thereno
not foundthe anchor names nothing — the entry is staleyes
not indexablethe target exists, but its document type is not in the catalog’s indexno

The third state matters. The catalog’s objects view covers the describable document types, not all of them. Without this distinction an anchor to, say, a scheduled event would read as missing, and check would demand edits to entries that are perfectly current. mxcli separates the two with a type-agnostic unit lookup that cannot miss a kind, because it never asks what kind anything is.

Is each entry in the right shard? A separate axis, not a fourth anchor state: every anchor can resolve and the entry still be in the wrong file. At least one anchor must belong to the shard the entry sits in; anchors into other modules are reported but do not fail, because a fact like “Sales.Order is committed by Finance.ACT_Post” genuinely spans two modules.

Misfiling is only decided when something resolved. An entry whose anchors are all not indexable is left alone — there is no evidence about where it belongs.

Requirements and slices

When the source of truth lives outside git, record it before building:

mxcli brain capture "Orders must be approvable by a manager" \
  --slice 02-approvals -a @Sales.ACT_Order_Approve -p app.mpr

--slice is the only signal needed: it files the entry in plan/02-approvals.md and makes it a requirement. Slices sort by name, so a numeric prefix is how a roadmap is sequenced — that is your choice, not a field mxcli maintains.

A requirement’s anchor points forward

Anchor points atAn anchor that does not resolve means
decisionwhat existsthe decision is stale — check fails
requirementwhat is intendednot built yet — normal, check passes

Same syntax, opposite meaning. Anchoring a requirement at a microflow that does not exist yet is correct: it is the forward reference that later becomes the progress signal.

This is not a theoretical distinction. Recorded as an ordinary entry, a single unbuilt requirement takes mxcli brain check to exit 1 — which is what made requirements a separate kind rather than more entries in the same files.

Progress is derived

mxcli brain plan -p app.mpr
SLICE           BUILT  PLANNED   UNANCHORED
01-accounts         1        0
02-approvals        0        1            1

1 of 3 requirements built, across 2 slice(s).

A requirement is built when its anchors resolve against the model. Nothing in the file says “done”: create the microflow a requirement points at and the count moves on the next run, with the plan file untouched.

That is the reason this belongs in mxcli rather than in a hand-kept markdown checklist. A status column is wrong the moment someone builds something, and nothing tells you; a derived one cannot be.

A requirement with no anchor is counted apart as unanchored rather than silently called planned — it cannot be measured until you say what will implement it.

Misfiling is not checked for slices: a slice spans modules by design.

Before there is a project

The brain does not need a model to exist: capture requirements from a specification first, create the app afterwards. Name the brain’s directory with -C; no store command opens, or needs, the project.

mxcli brain init -C ./App
mxcli brain capture "A planner can create a visit" -s 01-scheduling -a @Scheduling.Visit -C ./App
mxcli brain plan -C ./App

-p app.mpr still works and means the directory that .mpr is — or will be — in. The two are alternatives; passing both is refused.

plan and check then report what is true: nothing is built.

No model at /ws/App.mpr yet: 1 anchor(s) not checked, so nothing counts as built.

SLICE            BUILT  PLANNED   UNANCHORED
01-scheduling        0        1

0 of 1 requirements built, across 1 slice(s).

Every anchor is unverifiable — neither not found (that would report the whole store as stale) nor built. check exits 0, and --json carries "model_absent": true and an unverifiable count. Only a missing .mpr means this: one that exists but will not open is still an error.

Then create the project in the same directory

mxcli new App --version 11.12.1

mxcli new refuses a non-empty directory with one exception: a directory holding only a brain — docs/brain/ written by mxcli and its queue in .mxcli/brain/ — is adopted. The project is created around it, the store and the queue are left exactly as they were, and from then on -C ./App finds the one .mpr in the directory and counts the same requirements against it. Any other file in the directory, or a docs/brain/ mxcli did not write, is still refused. A file the project would overwrite is reported before anything moves.

So the brain lives where the project will live, not in a workspace above it: the directory is the project root from the first requirement onwards.

Open questions

An open question is a decision that has not been made yet.

mxcli brain capture "Do approvers see rejected orders?" --open -a @Sales.Order -p app.mpr

Its anchors are not checked. A question may name something that does not exist — often the question is precisely whether it should — so the staleness rule that keeps decisions honest would report every question as a defect. Measured: the identical anchor takes brain check to exit 1 as a decision and exit 0 as a question.

--open combines with --slice. A question about a slice’s scope is filed with that slice and counted apart from its requirements: an unanswered question is not outstanding scope, so it never inflates the slice’s numbers.

Answering it converts it in place:

mxcli brain resolve <id> "Yes, for 30 days
Agreed with the product owner; drives the overview filter and the access rule."

The entry keeps its id and its position in the file, and the question survives as the answer’s context. From that moment its anchors are checked like any other decision — which is the whole point of the transition, and is asserted by a test.

Both brain check and mxcli lint report unanswered questions until one is resolved. A question nobody answers is the one kind of entry that gets more expensive the longer it sits.

Where an entry came from

mxcli brain capture "A planner can create a visit" -s 01-scheduling -a @Scheduling.Visit \
  --source docs/sample-prd.md#L18-21 -p app.mpr

--source records the document an entry was taken from, and optionally where in it after a # — a line range, a heading, whatever locates it. It is rendered on its own line under the anchors and is in the JSON (staged --json):

## A planner can create a visit

Anchors: `@Scheduling.Visit` · id `72dfdd` · 2026-11-04
Source: `docs/sample-prd.md#L18-21`

The path is stored relative to the project directory — type it relative to where you are, or absolute — so it means the same on every checkout. The document has to be in the project: a source outside it, or one that does not exist, is refused, because provenance pointing at a file nobody else has is not provenance. If the document later disappears, brain check fails with NO SOURCE, as it does for an anchor that names nothing.

brain edit <id> --source … sets or changes it; an edit of the wording keeps it. The source is provenance, not content: the same requirement found in two places is still one entry.

Older mxcli builds read a shard with sources without complaint — they see the Source: line as the first line of the entry’s text and keep it when they rewrite the file. The queue is less forgiving: an older build that rewrites .mxcli/brain/staged.jsonl (on a capture, drop or promote) drops the source of the entries still queued there.

Reading the store from a program

mxcli brain list -C ./App --json
mxcli brain list --slice 01-scheduling --json -p app.mpr
mxcli brain list --open --json -p app.mpr      # --open=false: only settled entries

brain list --json is the committed side of brain staged --json: the same entry objects — id, kind, slice, open, title, body, anchors, source, date — each with the shard it is in, in store order (project.md, modules, slices; file order within each). kind is always present.

{
  "count": 1,
  "entries": [
    {
      "id": "72dfdd", "kind": "requirement", "slice": "01-scheduling",
      "title": "A planner can create a visit",
      "anchors": ["@Scheduling.Visit"], "source": "docs/sample-prd.md#L18-21",
      "date": "2026-11-04", "shard": "plan/01-scheduling"
    }
  ],
  "malformed": []
}

Use it rather than parsing the shard files: their format is mxcli’s to change. brief --json is the other read, meant for a prompt — it returns each shard’s rendered markdown. A block whose metadata line cannot be read is listed under malformed instead of being dropped, so a listing never quietly shows fewer entries than the files hold. Built-ness is not in the listing: that is derived from the model by brain plan, never stored on an entry.

Editing an entry

mxcli brain edit 66dcf4 "A planner can create a visit for one named engineer" -p app.mpr
mxcli brain edit a1b2c3 -a @Finance.ACT_Post -p app.mpr      # re-anchor, keep the text
mxcli brain move 66dcf4 --slice 02-dispatch -p app.mpr

An entry’s id is a handle — promote, resolve and prose cite it — so changing the wording keeps the id, the entry’s position in its file, and its date. The text replaces the title and body; -a, if given, replaces the anchors and is checked as capture checks it — with -a the text can be left out. A staged entry is edited in the queue, a committed one in its shard.

Two things move an entry to another file, and only these:

  • move --slice on a requirement. A decision has no slice: giving it one would make it a requirement, whose anchors stop failing when they go stale.
  • Re-anchoring a decision into another module, so check does not report it misfiled. A decision put in project.md on purpose (promote --to project) stays there.

An id is derived from the content once, at capture, and never again — so after an edit it no longer matches the text. Duplicates are therefore found by what an entry says, not by its id: capturing the edited wording again is refused (“Already committed as 66dcf4”), and capturing the original wording — which hashes to the edited entry’s id — is a new fact and gets an id of its own. An edit that would make one entry say the same as another is refused.

When to capture a decision

Capture needs a trigger, not good intentions. The reliable one:

You have had to correct the same thing twice.

The second correction is the signal — it will happen a third time, to someone else. The other trigger is choosing between real alternatives where the losing one would look reasonable to the next person: record the choice and what ruled the other out, or it gets re-litigated.

Size

mxcli brain show -p app.mpr
SHARD              ENTRIES    LINES     HEADROOM
project.md               3       28      92/120
Administration.md        2       16     224/240

Each shard has a line budget and promote refuses rather than exceeding it, naming the shard and its occupancy. project.md is the tightest: it is the only file loaded every session, so every line in it is charged to every session.

A plan slice gets far more room — it holds source material and is read when planning rather than loaded every session. It is still a budget, and that is the point: a slice too long to read is a slice that should be split. Here the cap does not merely bound context cost, it enforces the slicing discipline.

When a promotion is refused the answer is to condense or drop, not to raise the cap. The cap is what stops the store becoming a file nobody reads.

Sizes are computed on every run and are deliberately not written into any committed file, including the store’s own README.md — a figure in prose is stale the next time anyone promotes.

Renaming

mxcli rename rewrites matching anchors in docs/brain/ and in the staged queue, and reports the count:

Renamed entity: Sales.Order → Sales.PurchaseOrder
Updated 2 brain anchor(s): @Sales.Order -> @Sales.PurchaseOrder

Renaming a module also moves modules/<Old>.md to modules/<New>.md, so its entries do not immediately read as misfiled. Entry ids are not re-derived: an id is a handle (brain promote <id>, prose that cites one), and invalidating every reference to an entry in order to fix that entry’s references to the model would trade one dangling pointer for several.

It happens at the rename because it cannot be done afterwards. A decision’s anchor points backward, so a stale one shows up as NOT FOUND; a requirement’s points forward, so a stale one merely counts as PLANNED — indistinguishable from not built yet. Measured on a real project, a refactor moved the reported progress from 65/65 to 63/65 and the number was the only symptom.

Commands

CommandDoes
brain initCreates docs/brain/. Refuses a docs/brain/ it did not write
brain capture "<text>" [-a @Anchor]…Queues an entry. Never commits
brain staged [--since <id>] [--slice <n>] [--fail-if-empty]Lists the queue with the shard each entry would land in
brain capture --from-json <file|->Queues a JSON array of entries as one act: all validated first, duplicates skipped
brain promote <id>… [--to <shard>]Writes them into their shards, all or none
brain drop <id>Removes it from the queue or from its shard
brain capture "<text>" --source <doc>[#<locator>]Records which document, and where in it, the entry came from
brain edit <id> ["<text>"] [-a @Anchor]… [--source <doc>]Rewrites it in place, staged or committed; the id does not change
brain move <id> --slice <name>Moves a requirement to another slice; the id does not change
brain capture "<text>" --slice <name> [-a @Anchor]…Queues a requirement of that slice
brain capture "<text>" --open [-a @Anchor]…Queues an open question; anchors not checked
brain resolve <id> "<answer>"Answers it, turning it into a decision in place
brain plan [--slice <name>]Each slice’s requirements counted against the model
brain brief --slice <name> | --module <M>The reading pack: exactly the shards that work needs
brain check [--changed]Anchors resolve, entries filed correctly, plus slice progress
brain list [--slice <n>] [--kind <k>] [--open]The committed entries; with --json, as data
brain show [<shard>]Entries, lines and headroom per shard

Dropping the last entry from a module shard deletes the file, so the directory does not accumulate husks that read as “this module has decisions”.

Every command takes --json, so a dispatcher running one agent per slice can act on the answers rather than read them. capture --json returns the entry’s id, kind, slice, shard, added and a status of queued, already_queued or already_committed (a duplicate is reported with its twin’s id, and exits 0 as before); promote --json returns the id, kind, shard, the shard file’s path and a status of promoted or already_promoted. brain staged --since <id> is the slice boundary: note last_id before handing a slice off, pass it back afterwards, and --fail-if-empty exits 1 on a slice that recorded nothing.

Many entries at once

A tool that turns a specification into fifty requirements, and a person who accepts them all, should not mean fifty captures and fifty promotes — each is a place to stop half way. Both commands take a batch, and a batch is one act:

mxcli brain capture --from-json requirements.json -p app.mpr --json
mxcli brain promote 1a2b3c 4d5e6f 7a8b9c -p app.mpr

--from-json reads a JSON array (- for stdin) of {"text" | "title"+"body", "anchors": [...], "slice"?, "open"?, "source"?}, the input fields staged --json prints. Every item is checked before anything is queued, so one bad item fails the batch and names its index (items[3]). A duplicate — of a committed entry, a queued one, or an earlier item — is skipped and reported with the existing id, so the same list twice is a no-op.

promote with several ids resolves every id and checks each shard’s cap with all of the batch’s additions to it before writing anything; additions that each fit can still overrun the shard together, and then nothing is written. --to applies to every id. The shards are written first, then the queue once: interrupted between the two, the entries are committed and still queued, and running the same promote again completes it. With --json, both return results (one per item, in input order) plus totals; promote with a single id keeps its single-object shape.

--since rather than --slice, because capture --slice is what makes an entry a requirement — a decision found while building a slice carries no slice at all, and a slice’s findings are mostly decisions. The queue is append-only, so its own order is the honest boundary.

Linting and Reports

mxcli includes an extensible linting framework that checks Mendix projects for best practice violations, security issues, naming conventions, and architectural anti-patterns. The framework combines built-in Go rules with extensible Starlark rules.

Overview

The linting system provides:

  • 14 built-in Go rules – Fast, compiled rules for common issues
  • 27 bundled Starlark rules – Extensible rules covering security, quality, architecture, design, and conventions
  • Custom rule support – Write your own rules in Starlark
  • Multiple output formats – Text, JSON, and SARIF for CI integration
  • Scored reports – Best practices report with category breakdowns

Rule Categories

CategoryPrefixFocus
MDLMDL001-MDL007Naming conventions, empty microflows, domain model size
SecuritySEC001-SEC013Access rules, password policy, demo users, PII exposure
ConventionCONV001-CONV021Best practice conventions, error handling
QualityQUAL001-QUAL004Complexity, documentation, long microflows
ArchitectureARCH001-ARCH003Cross-module data, entity business keys
DesignDESIGN001Entity attribute count

Quick Start

# Lint a project
mxcli lint -p app.mpr

# List available rules
mxcli lint -p app.mpr --list-rules

# JSON output for CI
mxcli lint -p app.mpr --format json

# Generate a scored report
mxcli report -p app.mpr

Built-in Rules

mxcli ships with built-in rules implemented in Go. These rules are fast and always available.

The lint rules below run with mxcli lint. There is also a separate group of check-time rules that run with mxcli check — see Check-time Rules at the bottom of this page.

MDL Rules

RuleDescription
MDL001Naming conventions – Checks entity, attribute, and microflow naming patterns
MDL002Empty microflows – Detects microflows with no activities
MDL003Domain model size – Warns when a module has too many entities
MDL004Validation feedback – Checks for proper validation feedback usage
MDL005Image source – Validates image widget source configuration
MDL006Empty containers – Detects container widgets with no children
MDL007Page navigation security – Checks that pages called from microflows have appropriate access rules

Quality Rules

RuleDescription
QUAL006Required caption without the default language – A tab page caption (page, snippet or layout) with no text in the project’s DefaultLanguageCode. MxBuild refuses it with CE4899 “Empty caption. [German, Germany]”. Typically left behind by changing the default language after the pages were written. Error. Measured on Mendix 11.14: of the caption kinds tried (page title, group box, buttons, column header, label, dynamic text, title, enumeration value, menu item, message) only the tab page caption is required; page templates and building blocks are not checked

Security Rules

RuleDescription
SEC001Entity access rules – Ensures persistent entities have access rules defined
SEC002Password policy – Checks for secure password configuration
SEC003Demo users – Warns about demo users in production security level
SEC010Unreadable page data – A data grid, gallery, list view or combo box lists an entity that a user role allowed on the page cannot read (including its generalizations and specializations). At Production security it renders empty for that role and nothing else reports it. Info when the role reads only through an XPath-constrained rule; System entities use access measured from a deployed model, so a combo box over System.User gets its own message (it lists only the current user)
SEC011XPath through an unreadable association – A page data source’s XPath crosses an association that a user role allowed on the page may not read on the entity the step starts from (an association is a member of its FROM entity’s rules, and of its TO entity’s when the owner is Both). At Production security the page fails to open with “No access rights for Module$Entity/Module.Association”, while mxbuild reports nothing. Error. Entity access-rule XPaths are not checked: the runtime does not apply member access there (measured, 11.15)
SEC012Reachable page not readable – Predicts mxbuild’s CE2729: a page a user role can reach shows an attribute, or lists an entity, the role may not read. Reached means: a menu item (if the role may view the page), the role’s home page (its role-based one, else the profile’s; checked even when the role may not view it), and from there show-page actions, snippet calls, and microflows or nanoflows the role may execute, with their sub-microflows. Error. Measured against mxbuild 11.13 and 11.15; association reads, System entities, Marketplace modules and pages reachable only by URL are left out
SEC013Security level ignores rules – At security level Off the runtime applies none of the project’s entity access rules, page or microflow grants, and creates no demo user; at Prototype it guards pages and microflows but not entity access, so XPath constraints on access rules have no effect. Warns when the project’s own modules define what the level ignores. Only Production applies entity access

Convention Rules

RuleDescription
CONV011No commit in loop – Detects COMMIT statements, and CREATE / CHANGE with a COMMIT clause, inside LOOP blocks (performance anti-pattern)
CONV012Exclusive split captions – Checks that decision branches have meaningful captions
CONV013Error handling on external calls – Ensures external service calls have error handling. Not reported: a call inside a loop, where Mendix allows only Rollback (CE0644), and a call to a Java action that exists to throw (Throw…, e.g. CommunityCommons.ThrowException), whose Rollback is the point
CONV014No continue error handling – Warns against using CONTINUE error handling without logging
CONV020Created object never persisted – A microflow creates an object of a persistent entity without commit, and never commits, returns or passes it on, so the object is discarded when the microflow ends (details)
CONV021Create/change then commit – A Create or Change object activity without commit, directly followed by a Commit of the same object; one create/change with Commit set does both (details)
MDL-FLOW01Un-describable branch structure – Decision branches that re-enter each other’s paths, so DESCRIBE MICROFLOW cannot render them as nested IFs without changing what they mean
MDL-MAP04First over an object-rooted import mapping – An import from mapping or rest call … returns mapping activity already in the model stores Studio Pro’s First (ForceSingleOccurrence or Range.SingleObject) over a mapping that returns one object. mx check reports 0 errors; the activity throws key not found: Path(QName(None,),None,) when it runs. mxcli check refuses the same statement in a script under the same ID; this finds activities written before that check existed, or with exec --no-check. A list-rooted mapping’s First is legitimate and is not reported

CONV020 details

$Item = create Shop.Item (...) without commit makes an object in memory only. If the microflow then neither commits it nor hands it to anything that could, the object is gone when the microflow ends. Nothing else reports this: check, mx check and the build all pass, and the microflow runs without error. In the report that prompted the rule, an after-startup seed microflow logged “Seeded 20” and the table stayed empty.

The rule reports a create only when all of these hold:

  • it is in a microflow (not a nanoflow or a rule) and its commit setting is No;
  • the entity is persistent (a non-persistent entity is meant to be temporary), and is not a System or Marketplace entity;
  • nothing in the microflow, on any path, uses the variable except to read one of its members ($Item/Name) or to change it without commit.

Any other mention counts as passing the object on and keeps the rule quiet: commit, change … commit, returning it, an argument to a microflow, Java or JavaScript action, workflow, mapping or page, adding it to a list, using it as an association value on another object, delete, rollback, or the bare variable anywhere in an expression. The check is flow-insensitive: a commit on one branch only, or only in an error handler, counts, so that case is not reported. When the rule cannot tell, it stays quiet.

CONV021 details

$Order = create Shop.Order (Code = 'A1');
commit $Order;

is two activities doing what one does. Studio Pro’s Create object and Change object activities have a Commit option (Yes, Yes without events, No), and the expected way to model “create and save” or “change and save” is that option, not a separate Commit activity:

$Order = create Shop.Order (Code = 'A1') commit;

The suggestion carries the commit’s settings over: a Commit with events becomes Commit Yes, one without events Yes without events (commit without events), and Refresh in client is kept if either activity had it (refresh).

The rule reports a pair only when merging it cannot change what the microflow does:

  • it is in a microflow, the create/change has Commit No, and the commit names the object it creates or changes;
  • the commit directly follows it on the normal flow (not an error handler flow), and no other flow leads into the commit;
  • neither activity has custom error handling, which merging would move or drop.

Running Built-in Rules

Built-in rules run automatically with mxcli lint:

# Run all rules (built-in + Starlark)
mxcli lint -p app.mpr

# List all rules to see which are built-in
mxcli lint -p app.mpr --list-rules

Excluding Modules

System and marketplace modules often trigger false positives. Exclude them:

mxcli lint -p app.mpr --exclude System --exclude Administration

Check-time Rules (mxcli check)

These rules run with mxcli check (and the LSP, for real-time diagnostics) rather than mxcli lint. They cover pluggable-widget authoring and typed project-settings values.

RuleWhere it firesDescription
MDL-WIDGET01mxcli check + LSPUnknown property key on a pluggable widget. The property is not in the widget’s .def.json. Catches typos like optionsSourcType (missing e) before MxBuild does. Suggests the nearest known key.
MDL-WIDGET02mxcli check --post-migrationLegacy native widget found on a project that has a pluggable replacement available. Reports each occurrence with the qualified document name, widget instance name, and the recommended pluggable widget.
MDL-SET01mxcli check + LSPNon-integer value for an Integer-typed project setting (HttpPortNumber, ServerPortNumber, MaxJavaHeapSize, BcryptCost, DefaultTaskParallelism, WorkflowEngineParallelism). These used to be skipped silently while the statement still reported success.
MDL-I18N01mxcli check -pA script that changes DefaultLanguageCode leaves a tab page caption — stored in the project, or created by the script before the change — with no text in the new default. MxBuild reports CE4899 “Empty caption”. See Error Messages.
MDL-SET02mxcli check + LSPValue other than true / false for a Boolean-typed project setting (AllowUserMultipleSessions). Anything else was silently stored as false.
MDL-SET04mxcli check + LSPALTER SETTINGS section or key that does not exist (alter settings nosuchsection (…), a misspelled ExtraJvmParams), or a verb the section does not define. Reported with the message exec refuses it with, and the valid keys; check used to pass these and leave exec to stop halfway through the script.

Run mxcli check --help for usage. See Error Messages → MDL-WIDGET01 / MDL-WIDGET02 for cause-and-solution detail.

Starlark Rules

In addition to the built-in Go rules, mxcli bundles 29 Starlark-based lint rules. Starlark is a Python-like language that allows rules to be extended and customized without recompiling mxcli.

Bundled Starlark Rules

Security Rules (SEC004-SEC009)

RuleDescription
SEC004Guest access – Warns about overly permissive guest/anonymous access
SEC005Strict mode – Checks for strict security mode settings
SEC006PII exposure – Detects potentially sensitive data without restricted access
SEC007Anonymous access – Flags entities accessible to anonymous users
SEC008Member restrictions – Checks for overly broad member-level access
SEC009Additional security rules

Architecture Rules (ARCH001-ARCH003)

RuleDescription
ARCH001Cross-module data – Detects tight coupling between modules via direct data access. The System workflow entities (System.Workflow*) are exempt: a task page and a task inbox must use them
ARCH002Microflow-based writes – Ensures data modifications go through microflows
ARCH003Entity business keys – Checks that entities have meaningful business key attributes

Quality Rules (QUAL001-QUAL004)

RuleDescription
QUAL001McCabe complexity – Flags microflows with high cyclomatic complexity
QUAL002Documentation – Missing documentation across every document type: modules, entities, pages, microflows, workflows, Java/JavaScript actions and their parameters, REST services, mappings, constants (options)
QUAL003Long microflows – Warns about microflows with too many activities
QUAL004Orphaned elements – Detects unused entities, microflows, or pages (options)

Design Rules (DESIGN001)

RuleDescription
DESIGN001Entity attribute count – Warns when entities have too many attributes

Convention Rules (CONV001-CONV010, CONV015-CONV019)

RuleDescription
CONV001-CONV010Best practice conventions including boolean naming, page suffixes, enumeration prefixes, snippet prefixes
CONV015Validation rules – Checks for consistent validation patterns
CONV016Event handlers – Validates event handler configuration
CONV017Calculated attributes – Checks calculated attribute patterns
CONV018Module folder organization – A module past a readable size with every document loose in its root and no folders at all (options)
CONV019Navigation page URL – A page a navigation profile routes to that has no URL, so it cannot be linked to or bookmarked (details)

Additional convention rules cover access rule constraints, role mapping, microflow size and content.

QUAL002 options

Undocumented model elements are invisible to mxcli check and to the build — nothing fails, so nothing reminds you. QUAL002 is that reminder, and it sweeps every document type a user authors, not just the domain model.

On by default — one option per document type:

OptionElement
check_modulesModule
check_entitiesEntity
check_pagesPage
check_snippetsSnippet
check_building_blocksBuilding block
check_layoutsLayout
check_enumerationsEnumeration
check_microflowsMicroflow (nanoflows always exempt)
check_java_actionsJava action
check_java_action_paramsJava action parameter
check_javascript_actionsJavaScript action
check_workflowsWorkflow
check_constantsConstant
check_image_collectionsImage collection
check_data_transformersData transformer
check_business_event_servicesBusiness event service
check_rest_clientsConsumed REST client
check_published_rest_servicesPublished REST service
check_json_structuresJSON structure
check_import_mappingsImport mapping
check_export_mappingsExport mapping

Off by default — members, suppressed for volume rather than for being unimportant:

OptionElement
check_attributesEntity attribute
check_associationsAssociation

Plus min_activities (default 3): microflows with fewer activities are exempt, on the grounds that a three-step flow’s name says what a description would.

Why the split. A Java action has a handful of parameters, and Studio Pro renders each description in the dialog where someone wires up the call — an undocumented parameter is a blank field next to a name like pInput at exactly the moment a caller has to decide what to pass. A domain model, by contrast, has hundreds of attributes and associations, so the same check there is a wall of text rather than a signal. Turn those two on when you are actively working through documentation debt:

rules:
  QUAL002:
    enabled: true
    options:
      check_attributes: true
      check_associations: true
      check_pages: false        # e.g. if page names are the convention here
      min_activities: 5

Elements in System and Marketplace modules are never reported — that is code you did not write, and flagging Community Commons would bury the findings you can act on. This applies to every rule, not just QUAL002: the System module is not distinguishable by its Source (which is empty, exactly like your own modules), so it used to slip past the Marketplace filter into every rule that walks entities, pages, microflows or widgets. On a blank Mendix 9.24 project that was the difference between 60 findings and 8.

Adding a document type to the sweep is two rows: one in documentableSources (mdl/linter/context.go) naming the catalog table and its documentation column, and one in _DOC_KINDS (missing_documentation.star) giving the option name and the suggestion text. TestQUAL002_SweepsEveryAdvertisedDocumentType fails if the Go side advertises a kind the tests do not cover.

QUAL004 and CUSTOM002 options

Both rules decide by name prefix. QUAL004 (orphaned elements) does not report a microflow whose name starts with an entry-point prefix, because something outside the model calls it; CUSTOM002 (microflow prefix convention) reports a microflow whose name starts with none of the valid prefixes. Projects name things differently, so both lists are options — set them in lint-config.yaml rather than in the rule files, which mxcli init rewrites:

RuleOptionDefault
QUAL004entry_point_prefixesACT_, SCH_, WS_, REST_, OData_
CUSTOM002valid_prefixesACT_, SUB_, DS_, VAL_, SCH_, IVK_, OCH_
rules:
  QUAL004:
    options:
      entry_point_prefixes: [ACT_, SCH_, WS_, REST_, OData_, TEST_]
  CUSTOM002:
    options:
      valid_prefixes: "ACT_, SUB_, DS_, VAL_, SCH_, TEST_, GET_"

Write the list as a YAML list or as one comma-separated string; spaces around the commas are ignored. A configured list replaces the default, so repeat the default prefixes you still want. An empty list is a real setting: for QUAL004 it means no microflow is exempt by name. A value of any other shape (a number, a list holding a number) is not silently ignored — the rule runs with its default and the run reports a warning naming the option, listed with the rules that could not run and kept out of the score.

QUAL004 counts a microflow or page as used when anything in the catalog’s reference graph points at it — exactly what mxcli refs lists: a call, a page or widget, the navigation (a home page or home microflow, a menu item), a schedule, a published service, an event handler, a project setting. Every edge kind counts, including ones the catalog adds later, so the rule cannot fall behind it. A call from an MDL test script (tests/*.test.mdl) is not in the model, so a helper used only by tests is reported; give such helpers a prefix and list it in entry_point_prefixes. If refs finds nothing for a document you know is live, the gap is in the catalog, not the rule.

CONV018 options

Studio Pro lets a module hold folders, and every Mendix style guide expects them once a module grows past a handful of documents. Nothing reported their absence, so a project with 200 documents in one flat module scored clean.

CONV018 reports a module only when both halves hold:

  1. more than max_root_documents documents sit directly in the module root, and
  2. not one document in the module is in a folder.

The second half is what keeps the rule from nagging. A module that has started to organise itself — even one folder — is never reported, however much is still at its root: the team has evidently made a choice about where things go, and a linter guessing at the rest is noise. The first half exempts modules that are small rather than disorganised. Together they catch one thing: a module nobody has ever organised.

OptionDefaultMeaning
max_root_documents20Documents allowed at a module root before the module is expected to have folders
rules:
  CONV018:
    enabled: true
    options:
      max_root_documents: 40

Only the document kinds Studio Pro actually lets you file in a folder are counted. Associations, entities and external entities are not documents — they belong to the domain model or to a consumed service — so counting them would report a module as unorganised on the strength of elements nobody can move. That list is an allow-list in the rule (FOLDERABLE_KINDS), where it is visible and editable: a document type missing from it is undercounted, so the rule stays quiet rather than inventing a violation.

The projection behind it is the documents() builtin — every element of the App Explorer tree as (kind, name, qualified_name, module_name, folder), read from the catalog’s objects view so a new document type is covered without a second list to keep in step. It is the companion to documentable_elements(), which projects only what can carry documentation and therefore leaves out microflows and Java actions — the two kinds that fill up an unorganised module.

CONV019 details

A Mendix page is reachable at /p/<url> only if it has been given a URL. Without one the page exists solely at the end of a click path: it cannot be bookmarked, linked to from an email, reopened after a browser refresh, or captured directly with mxcli run --local --screenshot-url — verifying one screen means driving a browser through login and the menu.

Why only navigation targets. Measured on a blank Mendix 11.12.1 app, 0 of 16 pages carry a URL — Mendix’s own Home page and every Administration and FeedbackModule page included. Reporting every page without one would warn about every page of every project from the day it is created, which is noise rather than a finding. The pages a profile routes to are different: they are the app’s top-level screens, the ones a user lands on, bookmarks and shares. A page reached only from a button inside another screen is never reported.

Also never reported:

  • the login page and the not-found page — the platform routes to those itself, so a URL on them buys nothing;
  • microflow-valued menu items and home pages — the microflow decides what opens, so there is no page here to be addressable;
  • targets in System and Marketplace modules.

A page routed to several ways — a home page that is also a menu item — is one finding naming every route, not one finding per route.

The remedy is Url: 'orders' on CREATE PAGE, or the URL property in Studio Pro. An app that deliberately has no deep links can turn the rule off:

rules:
  CONV019:
    enabled: false

Behind it is the navigation_targets() builtin — (profile, kind, role, caption, page) for every page a navigation profile routes to, where kind is home, role_home or menu. Navigation was previously reachable only from the Go rules, so no Starlark rule could ask which pages a user can actually reach.

Where Starlark Rules Live

When you run mxcli init, Starlark rules are installed to:

your-project/
└── .claude/
    └── lint-rules/
        ├── sec004_guest_access.star
        ├── arch001_cross_module.star
        ├── qual001_complexity.star
        └── ...

Running Starlark Rules

Starlark rules run automatically alongside built-in rules:

mxcli lint -p app.mpr

Use --list-rules to see all available rules:

mxcli lint -p app.mpr --list-rules

Writing Custom Rules

You can extend the linter with your own rules written in Starlark, a Python-like language. A rule is a .star file in the project’s .claude/lint-rules/ directory; mxcli lint (and the lint statement) load every file there next to the built-in rules.

The complete rule API — every query builtin, every field of the structs they return and the values those fields take — is documented in the write-lint-rules skill, .claude/skills/mendix/write-lint-rules/SKILL.md, which mxcli init installs into the project. That file is the reference; this page shows the shape of a rule.

Rule file structure

A rule file defines its metadata as module-level constants and a check() function that takes no arguments and returns a list of violations:

# .claude/lint-rules/custom001_entity_prefix.star

RULE_ID = "CUSTOM001"
RULE_NAME = "EntityPrefix"
DESCRIPTION = "Entity names start with their module's abbreviation"
CATEGORY = "naming"
SEVERITY = "warning"            # error, warning, info or hint

def check():
    violations = []
    for entity in entities():
        prefix = entity.module_name[:3]
        if not entity.name.startswith(prefix):
            violations.append(violation(
                message = "Entity '%s' should start with '%s'" % (entity.name, prefix),
                location = location(
                    module = entity.module_name,
                    document_type = "entity",
                    document_name = entity.name,
                ),
                suggestion = "Rename to %s%s" % (prefix, entity.name),
            ))
    return violations

The project is read through builtins rather than a context argument: entities(), microflows(), pages(), widgets(), attributes_for(name), permissions_for(name), refs_to(name), xpath_expressions() and more — the skill lists them all. violation(...) and location(...) build the result.

Catalog depth

Some builtins read data that only a full catalog build produces (widgets(), xpath_expressions(), activities_for(), flows_for(), permissions(), permissions_for(), refs_to() / refs_from(), a page’s widget_count), and the graph builtins (cycles(), layer_of(), …) need the communities build. mxcli lint detects them in the rule’s source and builds the catalog deep enough. If the scan cannot see the call, declare it:

REQUIRES = ["full"]             # or ["communities"]

Severity levels

SeverityDescription
errorMust be fixed; fails CI pipelines
warningShould be fixed; potential issue (the default)
infoInformational; suggestion for improvement
hintLowest level; a nudge

Testing custom rules

# list the rules, including yours
mxcli lint -p app.mpr --list-rules

# run them
mxcli lint -p app.mpr

A rule file that fails to load is reported as skipped with the reason, and a rule that raises an error at run time is reported as an error violation carrying the Starlark message.

Best practices

  • Use a unique rule ID prefix (e.g. CUSTOM001) to avoid clashing with the built-in rules.
  • Write messages that say what to fix, and set suggestion where there is one.
  • Compare fields against the values the skill documents — a comparison against a value the API never returns matches nothing and reports a clean pass.
  • Try the rule on a real project before relying on it.

mxcli lint

The mxcli lint command runs all lint rules (built-in Go rules and Starlark rules) against a Mendix project and reports findings.

Basic Usage

# Lint a project
mxcli lint -p app.mpr

# With colored terminal output
mxcli lint -p app.mpr --color

Output Formats

Text (Default)

Human-readable output with rule IDs, severity, and messages:

mxcli lint -p app.mpr

JSON

Machine-readable JSON output for CI integration:

mxcli lint -p app.mpr --format json

SARIF

SARIF (Static Analysis Results Interchange Format) output for GitHub Code Scanning and other SARIF-compatible tools:

mxcli lint -p app.mpr --format sarif > results.sarif

This file can be uploaded to GitHub as a code scanning result.

Options

List Available Rules

mxcli lint -p app.mpr --list-rules

Shows all available rules with their IDs, names, severity, and source (built-in or Starlark).

Exclude Modules

System and marketplace modules often trigger false positives. Exclude them:

mxcli lint -p app.mpr --exclude System --exclude Administration

Colored Output

mxcli lint -p app.mpr --color

Baseline: Report Only New Findings

On a mature app the existing backlog buries the findings about the change you just made. Record a baseline once, commit it, and compare against it afterwards:

# Record today's findings
mxcli lint -p app.mpr --baseline lint-baseline.json --update-baseline

# Later: report only what is not in the baseline
mxcli lint -p app.mpr --baseline lint-baseline.json

The comparison prints a summary on stderr, so --format json and --format sarif stay clean:

Lint baseline lint-baseline.json: 1 new, 1518 already known, 0 fixed
  • Only the new findings are reported, and the exit code follows them alone.
  • A finding is matched on its rule and document (the document’s id where the rule gives one, so renaming a document keeps its findings), not on its message. Rewording a rule therefore does not make its findings new, while a further finding of the same rule in the same document is.
  • A rule that fails to run is never absorbed by the baseline.
  • A run scoped with --modules, --documents or --rules sees only part of the project: it reports new findings but not how many were fixed, and --update-baseline refuses it, since recording it would drop every finding outside the scope.
  • A missing or unreadable baseline file is an error, not “everything is new”.

The file is sorted JSON, so re-recording an unchanged project rewrites the same bytes and a committed baseline diffs only where findings changed.

CI Integration

GitHub Actions

- name: Lint Mendix project
  run: |
    mxcli lint -p app.mpr --format sarif > results.sarif

- name: Upload SARIF
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: results.sarif

JSON Processing

# Count findings by severity
mxcli lint -p app.mpr --format json | jq 'group_by(.severity) | map({severity: .[0].severity, count: length})'

# Filter for errors only
mxcli lint -p app.mpr --format json | jq '[.[] | select(.severity == "error")]'

Rules That Fail

A Starlark rule that reads a struct field this mxcli does not expose — usually a rule written for a newer mxcli — is reported at info level as rule <ID> needs a newer mxcli (<detail>), not as an error. Any other rule failure is an error (Starlark rule error: …). A rule’s configured severity in lint-config.yaml applies to its findings, never to its failure. mxcli report keeps all rule failures out of the score.

An option in lint-config.yaml that a rule cannot use — for example a number where the rule expects a list of prefixes — is reported at warning level as lint-config.yaml: rules.<ID>.options.<key> … the rule ran with its default. The rule still runs, on its built-in default, and the warning is kept out of the score like any other rule failure.

A rule file that does not load at all (for example, one calling a builtin this mxcli lacks) is skipped with a warning on stderr.

Exit Codes

CodeMeaning
0No findings
1Findings reported
2Error running linter

mxcli report

The mxcli report command generates a scored best practices report for a Mendix project. It runs all lint rules and aggregates findings into a category-based scorecard.

Basic Usage

# Generate a report (text output to terminal)
mxcli report -p app.mpr

Scoring Selected Modules

# score only your own modules
mxcli report -p app.mpr --modules Sales,Orders
mxcli report -p app.mpr -m Sales -m Orders

--modules (-m, as on mxcli lint) scores the named modules and nothing else: the rules skip every other module, and only findings located in a selected module count toward the score. Findings that belong to no module — project security, user-role mappings — are left out, because they would weigh the same on every module’s score. The report names the selection under the date, so a module score is not read as the project’s. --exclude and excludeModules in lint-config.yaml still win over --modules; a module named by both is warned about and not scored.

Output Formats

Markdown

mxcli report -p app.mpr --format markdown
mxcli report -p app.mpr --format markdown --output report.md

HTML

Visual report with color-coded scoring:

mxcli report -p app.mpr --format html --output report.html

JSON

Machine-readable report for CI pipelines:

mxcli report -p app.mpr --format json

Scoring Categories

The report scores the project across 6 categories on a 0-100 scale:

CategoryWhat It Measures
SecurityAccess rules, password policy, demo users, PII exposure
QualityDocumentation coverage, complexity, orphaned elements
ArchitectureModule coupling, data access patterns, business keys
PerformanceCommit-in-loop, query patterns
NamingEntity, attribute, microflow, and page naming conventions
DesignEntity size, attribute counts, association patterns

Each category shows:

  • A score from 0 to 100
  • Number of findings in that category
  • Specific rule violations with affected elements

Rules That Could Not Run

A lint rule that fails is a problem with the tooling, not the project, so it is not counted in the score, the summary or any category. The report lists it in its own section (“Rules That Could Not Run”; ruleFailures in JSON).

The common case is a rule written for a newer mxcli — one that reads a field this binary’s catalog does not expose. It is reported at info level as rule QUAL004 needs a newer mxcli ("microflow" struct has no .document_noun_title attribute). Any other failure is reported as a Starlark rule error. If the project’s tooling was written by a newer mxcli, mxcli warns about it on every command; see Syncing with Updates.

Writing Reports to Files

# Write HTML report
mxcli report -p app.mpr --format html --output report.html

# Write Markdown report
mxcli report -p app.mpr --format markdown --output report.md

# Write JSON report
mxcli report -p app.mpr --format json --output report.json

CI Integration

Use the JSON format to fail CI pipelines when scores drop below a threshold:

# Check if overall score is above 70
SCORE=$(mxcli report -p app.mpr --format json | jq '.overallScore')
if [ "$SCORE" -lt 70 ]; then
  echo "Quality score $SCORE is below threshold 70"
  exit 1
fi

Claude Code Companion

The mxcli companion is a Claude Code mod: a pane inside your Claude Code session that shows the Mendix app you are building while the agent builds it.

Early access. Mods are an early-access Claude Code feature, and the companion is at its first slice. Its plan is in docs/11-proposals/proposal-mxcli-companion-mod.md.

What it shows

  • Project — the app’s structure: modules, their domain model, folders and documents, project security, settings and navigation, as mxcli project-tree reports it.
  • MDL source — select a document to see its MDL, as mxcli describe prints it. Folders and other containers list what they hold instead.
  • Domain model diagrams — select a module’s Domain Model node in the desktop app or VS Code to see its entities, attributes, associations and generalizations, drawn where Studio Pro places them (mxcli describe --format svg domainmodel <Module>). Hover a box for its attributes. The terminal draws no pictures and lists the entities instead.

In a wide pane (90 columns or more) the tree sits on the left and the source on the right, like a file explorer; each column pages on its own with its ▲ / ▼ rows. A narrower pane stacks them.

The tree reloads by itself when the agent changes the model (mxcli exec, a writing -c one-liner, new, rename, marketplace). Press Refresh after changing the app elsewhere, for example in Studio Pro.

Getting it

mxcli init writes the companion to .claude/skills/mxcli-companion/ in a project set up for Claude Code, and mxcli init --sync-skills (run by the session hook on every start) keeps it current with your mxcli. Claude Code loads it from there once you trust the folder; there is nothing to install.

Start Claude Code from the project folder, then type:

/mxcli-project

The first time the companion loads in a project, a band above the prompt says it is installed and offers Open Project pane. It does not come back once you open the pane or press Not now.

A companion written during the current session — as the bootstrap prompt does — loads after /reload-plugins or in the next session; Claude Code reads plugins at session start.

Options

Both are set in Claude Code’s plugin configuration for mxcli-companion:

OptionDefault
mprPaththe single .mpr in the session folder, or one level down; with several, the pane asks
mxcliPath./mxcli when present, else mxcli on PATH

Notes

  • Every click runs mxcli, which reads the model. On a large app a document takes a few seconds the first time; going back to it is instant until the model changes.
  • The companion only reads. It never changes the project.
  • The pane draws on the surface that runs the session. A session started in a terminal and continued from the desktop app over remote control shows the pane in the terminal only.
  • It is for Claude Code only; other tools get what mxcli init gives them today.

Terminal UI (TUI)

mxcli includes a terminal-based interactive UI for browsing, inspecting, and modifying Mendix projects. Built with Bubble Tea, it provides a ranger/yazi-style Miller column interface directly in the terminal.

Quick Start

# Launch with a project file
mxcli tui -p app.mpr

# Resume a previous session
mxcli tui -c

# Launch without a project (opens file picker)
mxcli tui

The TUI opens in full-screen alternate screen mode with mouse support enabled.

Feature Summary

FeatureDescription
Miller column navigationThree-panel layout: modules, documents, preview
Vim-style keybindingsh/j/k/l navigation, / to filter, : for commands
Syntax highlightingReal-time MDL, SQL, and NDSL highlighting via Chroma
Command paletteVS Code-style : command bar with tab completion
MDL executionType or paste MDL scripts and execute them in-place
mx check integrationValidate projects with grouped errors, navigation, and filtering
Mouse supportClick to select, scroll wheel, clickable breadcrumbs
Session restore-c flag restores previous tabs, selections, and navigation state
File watcherAuto-detects MPR changes and refreshes the tree
Fullscreen previewPress Enter or Z to expand any panel to full screen
Tab supportMultiple project tabs in a single session
Contextual helpPress ? for keybinding reference

Architecture

The TUI delegates all heavy lifting to existing mxcli logic. The project tree is built by calling buildProjectTree() directly, while commands like describe, callers, check, and exec run mxcli as a subprocess. Diagrams open in the system browser via xdg-open or open.

Flags

FlagDescription
-p, --projectPath to the .mpr project file
-c, --continueRestore the previous TUI session (tabs, navigation, preview mode)

When launched without -p, the TUI presents a file picker that shows recently opened projects and allows browsing the filesystem for .mpr files.

Keyboard Reference

KeyAction
h / LeftMove focus left
l / Right / EnterMove focus right / open
j / DownMove down
k / UpMove up
TabCycle panel focus
/Filter current column
:Open command palette
xOpen MDL execution view
z / ZToggle fullscreen zoom on current panel
?Show contextual help
qQuit

Navigation and Layout

The TUI uses a Miller column layout for hierarchical browsing of Mendix projects. This design, inspired by file managers like ranger and yazi, shows the current context alongside parent and child items for spatial orientation.

Three-Column Layout

The default layout divides the terminal into three panels:

PanelContentWidth
LeftModules and folders~20%
CenterDocuments within the selected module (entities, microflows, pages, etc.)~30%
RightPreview of the selected document (MDL/NDSL rendering with syntax highlighting)~50%

The layout adapts dynamically as you navigate:

  • One panel: Only the module list is visible when no module is selected.
  • Two panels: Selecting a module expands the document list alongside it (35% + 65% split).
  • Three panels: Selecting a document adds the preview panel (20% + 30% + 50% split).

Each panel displays a breadcrumb trail at the top showing the current navigation path. For example:

MyModule > DomainModel > Customer

Breadcrumbs are clickable with the mouse. Clicking a segment navigates back to that level in the hierarchy. The navigation stack remembers your path so you can drill into nested folders and return.

Keyboard Navigation

The TUI uses vim-style keybindings for movement:

KeyAction
j / DownMove cursor down in the current panel
k / UpMove cursor up in the current panel
h / LeftMove focus to the left panel (or go back in navigation stack)
l / Right / EnterMove focus to the right panel (or drill into the selected item)
TabCycle focus between panels (left, center, right)
/Activate filter mode in the current panel
EscExit filter mode, close overlay, or cancel current action

Filtering

Press / to activate the filter input in the current panel. Type a substring to filter the list in real time. Only items matching the filter text are shown. Press Esc to clear the filter and return to the full list.

Mouse Support

The TUI enables mouse interaction for all panels:

ActionEffect
Click on an itemSelects it and focuses the panel
Scroll wheelScrolls the list up or down
Click on a breadcrumb segmentNavigates back to that level

Mouse coordinates are translated to panel-local positions using the layout geometry, so clicks always target the correct panel regardless of terminal size.

Fullscreen Overlay

Press Enter or Z on a selected document to open a fullscreen overlay showing the complete document content. The overlay includes:

  • A title bar with the document name and type
  • Scrollable content with syntax highlighting (MDL, SQL, or NDSL)
  • Hint bar at the bottom showing available keys

Press Esc or q to close the overlay and return to the three-column browser.

Zoom Mode

Press z to zoom the currently focused panel to fill the entire terminal. This is useful for reviewing long module lists or document trees. Press z again or Esc to return to the previous layout. The TUI remembers which panel was focused and which layout was active before zooming.

Tab Support

The TUI supports multiple project tabs within a single session. Each tab maintains its own:

  • Project file path
  • Navigation state (selected module, document, preview)
  • Panel focus and scroll positions

Tabs allow you to compare or cross-reference elements across different projects or different areas of the same project.

Contextual Help

Press ? at any time to open the help overlay. The help content is contextual – it shows keybindings relevant to the current mode (browser, overlay, command palette, or execution view).

Command Palette

Press : to open the command palette at the bottom of the TUI. The command bar provides VS Code-style command execution with tab completion and fuzzy matching.

Usage

  1. Press : to activate the command bar.
  2. Start typing a command name. Tab completion suggests matching commands.
  3. Press Enter to execute or Esc to cancel.

Available Commands

CommandDescription
:checkValidate the project with mx check
:runExecute the current MDL file
:callersShow callers of the selected element
:calleesShow callees of the selected element
:contextDescribe context of the selected element
:impactShow impact analysis of the selected element
:refsShow references to the selected element
:diagramOpen a diagram of the selected element in the system browser
:search <keyword>Perform full-text search across the project

Tab Completion

The command bar supports multi-level tab completion:

  1. Level 1 – Command name: Type the first few characters and press Tab to complete. For example, cal + Tab completes to callers.
  2. Level 2 – Subcommand: Some commands have subcommands. Tab cycles through available options.
  3. Level 3 – Qualified name: Commands that operate on elements (callers, callees, refs, impact, context) complete with qualified names from the project tree. For example, :callers MyMod + Tab expands to :callers MyModule.ACT_CreateCustomer.

Fuzzy matching allows partial input. Typing :cal matches :callers and :callees.

Code Navigation Commands

The :callers, :callees, :refs, :impact, and :context commands display their results in a fullscreen overlay with syntax highlighting. These commands require a prior REFRESH CATALOG FULL to populate the cross-reference index.

When the currently selected element in the browser is a microflow, page, or other navigable document, these commands automatically use it as the target. You can also provide an explicit qualified name:

:callers MyModule.ACT_CreateCustomer
:impact MyModule.Customer

The :search command takes a keyword argument and performs full-text search across all project strings:

:search validation
:search Customer

Results appear in a fullscreen overlay listing matching elements with their type and location.

Diagram

The :diagram command opens a visual diagram of the selected element in the system browser. It uses xdg-open on Linux or open on macOS to launch the default browser. This is useful for viewing domain model relationships or microflow graphs in a visual format.

MDL Execution

The TUI includes a built-in MDL execution view for running MDL statements directly from the terminal interface. Press x to open the execution view.

Opening the Execution View

From the browser mode, press x to switch to the MDL execution view. A full-screen textarea appears with line numbers and a placeholder prompt.

Keybindings

KeyAction
xOpen the execution view (from browser mode)
Ctrl+EExecute the MDL script in the textarea
Ctrl+OOpen a file picker to load an MDL file
EscClose the execution view and return to the browser

Writing and Executing MDL

The textarea supports standard text editing. Type or paste any MDL statements:

mdl 1;
LIST MODULES;

CREATE PERSISTENT ENTITY MyModule.Customer (
  Name: String(100),
  Email: String(200),
  Age: Integer
);

Press Ctrl+E to execute. The TUI writes the content to a temporary file and runs mxcli exec as a subprocess. During execution, a status indicator shows “Executing…” in the status bar.

Viewing Results

After execution completes, results appear in a fullscreen overlay with syntax highlighting. The overlay shows:

  • The command output for successful executions (e.g., confirmation messages, query results)
  • Error details for failed executions, prefixed with -- Error:

Press q or Esc to close the result overlay.

Tree Refresh

When an MDL script executes successfully (e.g., creating or modifying elements), the project tree automatically refreshes to reflect the changes. This means you can create an entity, close the result overlay, and immediately see it in the browser.

Loading from File

Press Ctrl+O to open a file picker dialog. The TUI attempts to use a native file picker:

  • Linux: zenity --file-selection or kdialog --getopenfilename
  • macOS: Native file dialog

The file picker filters for .mdl files. After selecting a file, its content is loaded into the textarea. A status message confirms the loaded file path. You can then review or edit the content before executing with Ctrl+E.

If no file picker is available, the TUI displays an informational message suggesting installation of zenity or kdialog.

Supported MDL Statements

The execution view accepts any valid MDL statement, including:

CategoryExamples
QueryLIST MODULES, LIST ENTITIES, DESCRIBE ENTITY MyModule.Customer
CreateCREATE ENTITY, CREATE MICROFLOW, CREATE PAGE
ModifyALTER ENTITY, ALTER PAGE, ALTER NAVIGATION
DeleteDROP ENTITY, DROP MICROFLOW, DROP PAGE
SecurityGRANT, REVOKE, CREATE USER ROLE
CatalogREFRESH CATALOG, SELECT FROM CATALOG.ENTITIES
SearchSEARCH 'keyword'

Multiple statements can be included in a single execution, separated by semicolons.

Project Validation

The TUI integrates with mx check to validate Mendix projects directly from the terminal interface. Errors, warnings, and deprecations are displayed in an interactive overlay with grouping, navigation, and filtering.

Running a Check

Use the :check command from the command palette (press : then type check) to validate the current project. The TUI runs mx check -j -w -d in the background and displays results in a fullscreen overlay.

Error Overlay

The check results overlay groups diagnostics by error code and deduplicates repeated occurrences:

mx check Results  [All: 8E 2W 1D]

CE1613 -- The selected association/attribute no longer exists
  MyFirstModule.P_ComboBox_Enum (Page)
    > Property 'Association' of combo box 'cmbPriority'
  MyFirstModule.P_ComboBox_Assoc (Page) (x7)
    > Property 'Attribute' of combo box 'cmbCategory'

Each group shows:

  • The error code and description
  • Affected documents with their type
  • Specific element details
  • Occurrence count for deduplicated entries (shown as (xN))

Filtering by Severity

The overlay supports filtering by diagnostic severity. Press Tab to cycle through filters:

FilterShows
AllErrors, warnings, and deprecations
ErrorsOnly errors
WarningsOnly warnings
DeprecationsOnly deprecations

The title bar reflects the active filter: [All: 8E 2W 1D] or [Errors: 8].

Error Navigation

The check overlay is a selectable list. Use j/k to move the cursor between error locations. Press Enter on a location to:

  1. Close the overlay
  2. Navigate the project tree to the affected document

After closing the overlay, the TUI enters check navigation mode. The status bar shows the current error position and navigation hints:

[2/5] CE1613: MyFirstModule.P_ComboBox_Enum  ]e next  [e prev
KeyAction
]eJump to the next error location in the project tree
[eJump to the previous error location
!Reopen the check overlay
EscExit check navigation mode

File Monitoring

The TUI watches the project file for changes. When it detects that the MPR file has been modified (e.g., after saving in Studio Pro or executing an MDL script), it can automatically re-run mx check and update the results. This provides a continuous validation feedback loop when making changes.

LLM Anchor Tags

The check overlay embeds faint structured anchor tags in its output for machine consumption. These tags are nearly invisible in the terminal but are preserved when copying text or taking screenshots:

[mxcli:check] errors=8 warnings=2 deprecations=1
[mxcli:check:CE1613] severity=ERROR count=6 doc=MyFirstModule.P_ComboBox_Assoc type=Page

These anchors enable AI assistants to parse check results from terminal screenshots or clipboard content, providing structured data for automated error analysis and fix suggestions.

Status Badge

When check results are available, a compact badge appears in the TUI status bar showing the diagnostic summary:

8E 2W 1D

This badge updates whenever a new check completes, providing an at-a-glance project health indicator without opening the full overlay.

Session Management

The TUI supports session persistence, allowing you to restore your previous browsing state when relaunching the tool.

Restoring a Session

Use the -c or --continue flag to restore the previous session:

mxcli tui -c

This restores:

  • Open project tabs and their file paths
  • Navigation state (selected module, document, scroll positions)
  • Panel focus and layout configuration
  • Preview mode settings

If the session file cannot be loaded (e.g., first launch or corrupted state), a warning is printed to stderr and the TUI starts fresh.

Combining with Project Flag

When using -c with -p, the explicit project path takes precedence:

# Restore session but open a specific project
mxcli tui -c -p app.mpr

When using -c alone, the project path is loaded from the saved session. If the session contains multiple tabs, the first tab’s project path is used.

Session Storage

Session state is stored locally in the user’s configuration directory. The TUI automatically saves session state when exiting normally (via q).

Project File Picker

When launching without either -p or -c, the TUI presents an interactive file picker:

mxcli tui

The picker shows:

  • Recently opened projects (from session history)
  • Filesystem browser filtered to .mpr files

Select a project and press Enter to launch the TUI. The selected project is saved to the history for future sessions.

Session History

The TUI maintains a history of opened projects. This history is separate from the full session state and persists across sessions. It powers:

  • The file picker’s “recent projects” list
  • Session restore when using -c

Each time a project is opened, it is added to the history via tui.SaveHistory(projectPath).

Testing

mxcli includes a testing framework for a Mendix app’s own logic: each test calls into the running app — a microflow, a retrieve — and asserts on what came back or what was written. It is not a check that an MDL script parses; that is mxcli check.

Overview

The testing framework supports two test file formats:

FormatExtensionDescription
MDL test files.test.mdlTests as MDL blocks with annotations
Markdown test files.test.mdLiterate tests with prose and embedded mdl-test blocks

Prerequisites

Tests execute against a real Mendix runtime. Docker is one way to get one, not the only one — --local uses mxcli’s own runtime with no daemon, and is the faster path (see Running Tests for the mode comparison and the --watch / --attach loops). Either way the runner:

  1. Installs a temporary MxTest module holding one microflow per test
  2. Boots (or reuses) the app and invokes each test over a token-guarded endpoint
  3. Evaluates that test’s assertions and reports pass, fail or error
  4. Removes what it installed, leaving the project byte-identical

Quick Start

# Run all tests in a directory, including its subfolders (folders starting
# with _ are skipped; name one to run it: mxcli test tests/unit/_control)
mxcli test tests/ -p app.mpr

# Run a specific test file
mxcli test tests/sales.test.mdl -p app.mpr

Test Workflow

  1. Write test files using .test.mdl or .test.md format
  2. Name each test with @test and assert with @expect / @verify / @throws
  3. Run tests with mxcli test
  4. Review results

Example Test

-- tests/customer.test.mdl

/**
 * @test a new customer is active by default
 * @expect $customer/IsActive = true
 */
$customer = call microflow MyFirstModule.ACT_CreateCustomer(Name = 'Acme');
/

/**
 * @test the seed microflow writes five customers
 * @cleanup none
 * @expect count($customers) = 5
 * @verify select count(*) as n from MyFirstModule.Customer = 5
 */
call microflow MyFirstModule.ACT_Seed();
retrieve $customers from MyFirstModule.Customer;
/

An assertion the runner cannot evaluate is reported as an ERROR, never as a pass — see Test Annotations.

Playwright UI Testing

For browser-based testing that verifies widgets render correctly in the DOM, see Playwright Testing. mxcli test asserts on what the app’s logic returns and writes; Playwright testing asserts on what the app renders – that widgets are visible, forms accept input, and navigation works.

# MDL validation (this page)
mxcli test tests/ -p app.mpr

# Browser-based UI verification
mxcli playwright check /p/Customer_Overview -p app.mpr   # does it render? one call, text verdict
mxcli playwright verify tests/ -p app.mpr

Test Formats

mxcli test reads two file formats: .test.mdl for plain MDL tests, and .test.md for tests embedded in documentation. Both describe the same thing — a named test, some MDL to run against the app, and what must be true afterwards — and both use the same annotations.

A test’s body calls into the app. It is not a script that builds a project: the statements run against a booted runtime, and the assertions are about what they returned or wrote.

.test.mdl

A test is a javadoc comment followed by its statements. A line containing just / ends it, the way it ends any MDL block:

/**
 * @test concatenating a name
 * @expect $result = 'John Doe'
 */
$result = call microflow MyModule.ConcatNames(
  FirstName = 'John', LastName = 'Doe'
);
/

/**
 * @test the seed microflow writes five brands
 * @cleanup none
 * @expect count($Brands) = 5
 */
retrieve $Brands from eShop.CatalogBrand;
/

Two rules about layout are worth knowing, because getting them wrong used to be silent:

  • A file may open with a header comment — a /** … */ block or -- lines — and it is not part of the first test. The test’s own doc comment is the last one above its statements. A /** … */ header may carry @setup, which then applies to every test in the file.
  • One @test per block. Because the / is what ends a test, omitting it merges two tests into one. That is refused by name rather than resolved, since either resolution runs one of the two and drops the other.
  • A file may start with the language header mdl 1;, before any doc comment, exactly as a script does (Language Versions). Every test body in the file is then read as mdl 1 by mxcli test, mxcli check and the editor; without it the bodies are mdl 0. The / lines stay: they are the test format’s separators, not MDL terminators. In a .test.md file the header is the first line inside each mdl-test block, and the blocks of one file must agree. A suite runs as one script, so mxcli test refuses files on different versions — mxcli fmt --upgrade -w adds the header to the rest.

.test.md

The same tests, inside mdl-test fenced code blocks, with prose around them. One block is one test; everything outside the fences is documentation and is ignored.

# Customer module

## Names are concatenated in display order

Given a first and a last name, `ConcatNames` returns them separated by a space:

```mdl-test
/**
 * @test concatenating a name
 * @expect $result = 'John Doe'
 */
$result = call microflow MyModule.ConcatNames(
  FirstName = 'John', LastName = 'Doe'
);
```

Use it when the reasoning around a test is worth as much as the test — a specification that stays honest because it runs.

File organisation

mxcli test takes a file or a directory. A directory run picks up every *.test.mdl and *.test.md in it (not recursively) and reports them as one suite.

tests/
├── domain-model.test.mdl
├── microflows.test.mdl
├── security.test.mdl
└── ordering.test.md

Names are yours to choose; the .test.mdl / .test.md suffix is what makes a file a test file.

Test Annotations

Annotations describe what a test is and what must be true when it finishes. They live in the javadoc comment above a test’s statements, one per line, each opening its line:

/**
 * @test dealing a board writes 81 cells
 * @cleanup none
 * @expect $result = 'ok'
 * @verify select count(*) as n from Sudoku.Cell = 81
 */
$result = call microflow Sudoku.ACT_DealGame();
/
TagPurpose
@test <name>Names the test. Required — a doc comment without it is not a test.
@expect <condition>A Mendix expression over the body’s variables that must be true. Repeatable.
@verify <oql> <op> <value>An OQL post-condition on the database. Repeatable.
@throws ['<message>']The body is expected to raise an error; with a message, one whose text contains it.
@setup <Module.Microflow>A microflow to call before the body. Repeatable.
@cleanup rollback|noneWhether the test’s writes survive it. rollback is the default.

A tag is read only when it opens its line (after the javadoc * and its indentation). Quoting one inside a sentence — `@expect $x = 1` — is documentation, not an assertion.

@test

Names the test, and marks the doc comment as one. Everything between that comment and the / that ends the block is the test’s body.

A file may open with a header comment, in either spelling, and it is not part of the first test. Exactly one @test may appear per block: the / is what ends a test, and leaving it out is refused by name rather than silently running one of the two tests it merges.

@expect

Any Mendix expression that must evaluate to true — not a fixed $var = value shape:

@expect $result = 'John Doe'                    -- equality
@expect $product/Name != 'Widget'               -- inequality (<> is accepted too)
@expect length($result) = 81                    -- built-in functions
@expect find($result, '0') >= 0 and $count > 3  -- and / or / not(...)
@expect $status = MyModule.Status.Open          -- enumeration values
@expect count($Customers) = 5                   -- how many rows a list holds

count($list) is the one aggregate an assertion can make. Counting a list is a Mendix Aggregate list activity, not an expression function, so mxcli lifts it into that activity ahead of the decision that evaluates the condition. sum, average, minimum and maximum aggregate an attribute over the list, which an assertion has no way to supply — call a microflow that returns the figure and assert on its result.

An assertion the runner cannot compile is an ERROR, never a pass. Unknown functions, wrong arity, unbalanced parentheses, and expressions that evaluate to a value rather than a condition are all rejected by name:

ERROR  a self-evident falsehood
       @expect randomInt($result) = 1: randomInt() is not a Mendix expression
       function at column 1 ("randomInt")

A failing assertion reports what came back, whenever the observed value’s type is pinned down by the assertion itself:

FAIL  the board is 81 squares
      expected length($result) = 81, actual: 27

A test with several @expect lines passes only when every one of them holds, and it reports the first that does not. The Docker / --legacy-runner path evaluates them all, and adds how many more failed — (and 1 more failure in this test).

@expect cannot be combined with @throws: a body expected to fail produces no result to assert on, so the combination is refused rather than ignored.

@verify

@expect only sees what a microflow returned. Most Mendix microflows are side effects, so @verify asserts on the rows one left behind — an OQL query, a comparison operator, and the value it must satisfy:

@verify select count(*) as n from Sudoku.Cell = 81
@verify select count(*) as n from Sudoku.Cell where Value = 0 > 0

The query runs after the microflow returns, over the same admin API mxcli oql uses. Three rules follow:

  • The result must be one row and one column (select count(*) as n …, or one attribute of one row). OQL requires the column to be named, so write as n.
  • @cleanup rollback — the default — is refused with @verify: the writes would be undone before the query could see them. Add @cleanup none.
  • The legacy after-startup runner refuses the whole suite, because its tests run during boot with nothing to query yet. Use --local or --attach.

A query that cannot be evaluated is an ERROR, distinct from one that returns the wrong value (FAIL).

@throws

Marks a body that is expected to raise an error. The verdict starts as a failure and only the error handler clears it, so a body that completes normally fails the test.

/**
 * @test rejects an empty order
 * @throws 'validation failed'
 */
$result = call microflow Sales.ACT_Submit(Order = $empty);
/

The message is matched as a substring of the error Mendix raised, so an expectation names the part of the message the test can predict. A real message routinely carries an activity name or an object id, which is why the match is not equality:

FAIL  rejects an empty order (6ms, 1 assertion)
       expected an error containing 'validation failed',
       actual: Could not find object of type 'Sales.Order'

Write @throws on its own to expect any exception, when the message is not the thing under test:

/**
 * @test rejects an empty order
 * @throws
 */
$result = call microflow Sales.ACT_Submit(Order = $empty);
/

Anything after @throws that is not a single-quoted string is an error, not a message — @throws not found is refused rather than read as an unquoted one.

The handler is attached to every call in the body: call microflow, call nanoflow, call java action, call external action, call web service, call workflow, call rest service, send rest request and execute database query. An error raised by any other statement — a commit, a change, a create — is not caught; move it into a microflow and call that. The same handler turns an unexpected error in a test without @throws into a FAIL reading exception during execution: <message>.

A call with its own error handling

A call that already has an on error clause keeps it and gets no second handler, so a test runs the statement exactly as production code does. What the test then reports depends on the clause (measured on Mendix 11.13 with a Java action that throws):

Clause on the callWithout @throwsWith @throws
noneFAIL exception during execution: <message>PASS when the message matches
on error rollbacksame as none — it is Mendix’s default spelled out, so the runner’s handler takes its placesame as none
on error continuethe error is swallowed; the assertions decide ($r is empty)FAIL expected an exception but none was thrown
custom handler that ends normallyFAIL not every assertion was evaluated when an assertion reads the call’s result; an assertion on what the handler did is evaluatedFAIL expected an exception but none was thrown
custom handler that re-raises (raise error)ERROR with the message — the error leaves the test microflowERROR with the message

A custom handler that ends normally rejoins the flow after every statement that reads the failed call’s output, because that variable does not exist on the error path. The assertions read exactly that, so the runner counts the assertions it evaluated and fails the test when the count is short, instead of reporting a pass for assertions that never ran. To test the handler’s path, assert on something the handler sets: declare $Failed Boolean = false; before the call, set $Failed = true; inside its handler, and @expect $Failed = true. That assertion does not read the call’s result, so it is evaluated, and passes only when the handler ran.

Under the Docker / --legacy-runner path an error that leaves the test — the re-raising row above — ends the whole runner: that test is reported as an ERROR that started and gave no verdict, and every later test as not executed.

@setup

Names a microflow to call before the test’s own statements — a fixture in a Mendix app is a microflow, so there is nothing to declare:

/**
 * @test the seed microflow writes five brands
 * @setup eShop.ACT_SeedCatalog
 * @cleanup none
 * @expect count($Brands) = 5
 */
retrieve $Brands from eShop.CatalogBrand;
/

Repeat it to compose fixtures; they run in the order written. Declare it once in the file’s header comment and every test in the file gets it, the file’s fixtures first:

/**
 * Seeds every test below.
 * @setup eShop.ACT_SeedCatalog
 */

A header may carry only @setup. @expect, @verify, @throws and @cleanup describe one test’s execution, so a header carrying one is refused by name.

The setup runs inside the test’s transaction, so under the @cleanup rollback default it is undone with the test and every test starts from the same state. A failing setup is an ERROR naming the microflow, not a FAIL: the test never ran, and a broken fixture should not read as a broken feature.

@setup calls a microflow with no arguments — a fixture that needs arguments gets a wrapper microflow. There is no @teardown; @cleanup rollback is the teardown.

@cleanup

rollback (the default) wraps the test in a transaction that is rolled back when it returns, so its writes do not survive it. none leaves them in place — needed whenever a later test, or a @verify, has to see them. An unknown strategy is a parse error.

Running Tests

The mxcli test command executes test files and reports results.

Prerequisites

Tests need a Mendix runtime to execute against. There are two ways to get one, and Docker is only needed for the first:

  • Docker — the container path. Requires a running Docker daemon.
  • --local — mxcli’s own runtime, no daemon involved. It uses its own ports (app 8081, admin 8091, mxbuild serve 6544) and its own <project>_test database, so a mxcli run --local dev loop can keep serving the same project while tests run.

Each port is probed before the project is touched. If one is taken — an editor helper on 8081 is the usual suspect — the run stops with the port, what it was for, the flag that moves it, and on Linux the process holding it. Move it with the same flags run --local has (refused with --attach and on the Docker path):

mxcli test tests/ -p app.mpr --local --app-port 9081 --admin-port 9091 --serve-port 7544

--local provisions that scratch database on PostgreSQL by default. On a machine without PostgreSQL — where it would stop with no local PostgreSQL superuser available to create the role/database — pass --db-type hsqldb to boot on the runtime’s built-in file database instead: no server and no provisioning. It takes the same values as run --local --db-type (postgresql, the default, or hsqldb), keeps the <project>_test scratch name so it never shares files with a run --local --db-type hsqldb dev loop, and is refused with --attach and on the Docker path, which do not boot a database of their own.

Either way, each --local run starts on an empty <project>_test database: the file database’s files are removed before the boot, and on PostgreSQL everything the app role owns inside <project>_test is dropped (DROP OWNED BY CURRENT_USER, run in that database only — the dev loop’s <project> database is untouched). Data a previous run committed, or an after-startup flow seeded under an earlier model, therefore cannot make an idempotent init skip seeding.

mxcli test tests/ -p app.mpr --local --db-type hsqldb

A --local run boots the app with the same constant values mxcli run --local uses — the project configuration’s shared overrides layered over each constant’s default — and prints what it applied. Use --configuration <name> to choose between several, and --constant Module.Name=value (repeatable) to set one for this run only — it wins over everything and is never written anywhere. For a value that should persist on this machine without being committed, use mxcli constant set (see Constant values below). A literal value on the command line shows in shell history and in ps; write --constant Module.Name=@env:VAR to read it from an environment variable, or --constant Module.Name=@file:path to read it from a file that only you can read (chmod 600; owner-only check on Unix, not checked on Windows). An unset or empty variable, an empty file, or an open file is an error, never an empty value, and a value that must start with @ is written @@…. A constant the project does not declare is refused before anything boots. --attach takes none of these, since it inherits the constants of the app it attaches to. This matters whenever a test asserts on something a constant feeds: the two modes used to disagree silently.

--local also downloads what it needs on first use. To pre-cache it:

mxcli setup mxbuild -p app.mpr

The mx binary, when you need it directly:

EnvironmentPath
Dev container~/.mxcli/mxbuild/{version}/modeler/mx
Repositoryreference/mxbuild/modeler/mx

Constant values

Highest layer wins:

LayerSet withIn git?
this run--constant Module.Name=value (or =@env:VAR, =@file:path)no
this machinemxcli constant set Module.Name valueno — gitignored, 0600
this configurationalter settings constant … in configuration 'X'yes
defaultcreate constant … default '…'yes

mxcli constant list shows the winner for each constant and which layer set it, masking machine-local values unless --show-values is passed.

A machine-local value normally takes effect at the next boot. mxcli constant set … --apply pushes it into a mxcli run --local that is already up, as update_configuration followed by reload_model — both are needed, since the first call only stages the change.

Basic Usage

# Run all tests in a directory, including its subfolders
mxcli test tests/ -p app.mpr

# Run a specific test file
mxcli test tests/sales.test.mdl -p app.mpr

# Run markdown test files
mxcli test tests/integration.test.md -p app.mpr

Discovery walks the directory and every subfolder, in path order. Below the folder you name it skips folders starting with . (.git, .mxcli) and folders starting with _, the Go convention. Use _ for a suite that should stay out of the default run, such as a positive control that is meant to fail:

tests/unit/Sales/orders.test.mdl      # run by `mxcli test tests/`
tests/unit/_control/control.test.mdl  # skipped, and named in the output
# Run the control on its own: the folder you name is always read
mxcli test tests/unit/_control -p app.mpr

Skipped _ folders are named in the output (after the Found N test(s) line of a run, at the end of a --list), so they do not disappear silently. A run that finds no tests exits 1. --list exits 0 even when it finds none, and then prints the reason, including any _ folders it skipped, on stderr.

Discovery is fully recursive and has no other skip list, so point mxcli test at the folder that holds your tests (tests/), not at the project root. From the project root, mxcli test . searches deployment/, node_modules/, javasource/, vendorlib/ and every other folder, which is slow, and runs any stray *.test.mdl it finds there.

Two more rules follow from the walk:

  • A symlinked folder below the folder you name is not followed (the walk reports it as a file, so it is ignored without a message). A symlinked folder you name yourself is followed, and errors and listings show the path you typed.
  • A folder that cannot be read (a locked folder, a root-owned folder in a mounted volume) is reported by name as “could not be read”, the run exits non-zero, and the tests in the other folders still run. Only a root that cannot be read stops the run.

Choosing a mode

Boot cost per runDatabaseNeeds Docker
mxcli test …container restartthe container’syes
--local~30s<project>_testno
--local --watch~30s once, then ~2s<project>_testno
--attachnonethe running app’sno
--attach to an app started with --allow-callnone, and no build when the tests compile for the executorthe running app’sno
# No Docker daemon needed
mxcli test tests/ -p app.mpr --local

# Keep the runtime warm; re-runs on every test or model change (Ctrl-C to stop)
mxcli test tests/ -p app.mpr --local --watch

# Attach to an app you already have running — no boot at all
mxcli run  --local --test-endpoint -p app.mpr    # terminal 1
mxcli test tests/ -p app.mpr --attach            # terminal 2

--local builds with the same mxbuild run --local resolves: Studio Pro’s bundled one on macOS and Windows, the cached CDN download on Linux (the CDN publishes Linux binaries only). To point it somewhere else — a Studio Pro installed outside Program Files, say — pass --mxbuild-path, or set MXCLI_MXBUILD_PATH for a wrapper or CI step that cannot add a flag. The flag wins when both are set.

mxcli test tests/ -p app.mpr --local --mxbuild-path "C:\Program Files\Mendix\11.11.0\modeler\mxbuild.exe"

--reuse-build (with --local, not --watch) skips the injection and the build when the project, the test files and mxcli are unchanged since the last run: the deployment that run left is booted as it is. Any difference — a changed file, a deployment something else rebuilt — builds as usual.

--watch is the everyday loop: edit a test or the microflow under test, and the verdict lands in about two seconds.

A test run that writes its test microflows into a project a run --local --watch dev loop is watching (--local, or --attach with a test that has to be built) holds that dev loop’s watcher while it owns the project: the dev loop does not rebuild, or restart its app, for the injection, run status shows held by a test run (pid …), and when the run has put the project back exactly the watcher finds nothing to build. An edit made in the meantime is built once the run ends. A hold whose run died is ignored.

A run that builds (--local, or a built test under --attach) writes the deployment/ folder the dev loop serves from too — mxbuild cannot put it elsewhere. When it did, the dev loop builds its own back once the run ends, so a later --attach finds the app’s build; that one build can be a restart (measured: after --local, a restart; after a mixed --attach, a reload). A --test-endpoint dev loop’s endpoint is left in place by --local, which chains to the project’s own after-startup microflow rather than to the endpoint (ako/mxcli#2119). For a gate in the middle of such a loop, --attach costs nothing to the running app.

--attach skips even the first boot, at one cost worth knowing: the tests run against the running app’s database rather than a scratch one, so they can leave data behind in the app you are looking at. It needs the dev loop to have been started with --test-endpoint, because the endpoint’s handler is registered by the after-startup microflow and cannot be added to an app that is already up.

On Mendix 11.14.0 an attach is always refused. That version’s mxbuild --serve fails every build after its first, even for an unchanged model, and says nothing about whether the runtime needs a restart. So the attach builds on a fresh serve process instead, and a fresh process’s first build asks for a restart whatever changed — which an attach cannot do, the runtime being the other process’s. Use --local on 11.14.0; 11.15.0 builds incrementally again.

Running a test without building it (--allow-call)

An attach normally injects the suite’s test microflows and builds them into the running app. When that app was started with --allow-call, a test whose statements the endpoint can execute is not injected or built at all: mxcli compiles it into a list of steps, the app runs them with the Mendix runtime API, and every expression goes to Mendix’s own evaluator (Core.evaluateExpression). Editing a test then costs one HTTP round trip.

mxcli run  --local --watch --test-endpoint --allow-call -p app.mpr    # terminal 1
mxcli test tests/ -p app.mpr --attach                                 # terminal 2

You still write .test.mdl. The executor runs these statements: declare, set, create, change, commit, delete, call microflow, a database retrieve with an optional where and first, a retrieve over an association, and the list aggregates count, sum, average, minimum and maximum over an attribute. It also handles @expect (including count(...)), @throws, @setup, @cleanup and @verify. A test with anything else (a decision, a loop, a list operation such as find or filter, a retrieve with sort by/limit, an aggregate by expression, an on error clause other than rollback, a nanoflow or Java action call) is injected and built as before. Only that test is: the rest of the run still goes straight to the app, and mxcli prints which test and which statement decided it. The verdicts are the same on both routes. This applies to a one-shot --attach; test --attach --watch still injects and builds.

Three things are checked before a test runs, because the runtime’s evaluator returns empty for a name it does not know instead of failing:

  • every $variable an expression reads must be declared earlier in the test (checked by mxcli, then again by the app);
  • every enumeration value an expression names must exist, so Module.Enum.TYPO is an ERROR rather than a comparison against empty;
  • the model on disk must not be newer than the build the app is serving. If it is, the run is refused: with --watch, mxcli run wait -p app.mpr lets the dev loop apply the change; without it, mxcli run restart -p app.mpr.

--allow-call widens what the endpoint can do. Without it, the endpoint only runs microflows the runner generated. With it, the endpoint calls the microflows a test names and reads and writes data in a system context. It still needs the run’s token and a loopback caller, accepts only POST, and is off unless you pass the flag. Under --hub the loopback check does not help: the tunnel client connects from loopback, so the token is the only guard, and run warns when both flags are given.

How tests execute

--local — the test endpoint. One microflow is generated per test, plus a Java action that registers a token-guarded HTTP endpoint. The app boots once; startup only registers the endpoint and runs no tests. Each test is then invoked by name over HTTP and returns its verdict in the response.

Two consequences when reading a failing run:

  • A test that throws fails only itself and is reported as an error with the root-cause message; the next test still runs.
  • Results are returned, not recovered from the runtime log.

The endpoint executes microflows under a system context, so it is gated: it is not registered at all without a per-run token in the runtime’s environment, every request must present that token, non-loopback callers are refused, and it will only ever invoke the generated MxTest.Test_* microflows. The token is never written into your project.

A run leaves the project byte-identical

mxcli test injects an MxTest module, builds, runs, and removes it again. When cleanup succeeds the .mpr is restored byte-for-byte, so git status is clean after a run and a “run the tests, then assert the tree is clean” CI step holds.

Restoring the model is not enough on its own: every unit write stamps a fresh UUID into the .mpr’s _Transaction row, and the inject/remove cycle relays SQLite’s pages, so the file differs even once its content matches. Version control compares bytes, and a .mpr diff is opaque.

The restore is declined when cleanup failed, or when the mprcontents/ tree changed during the run — in both cases the project is not in the state the snapshot describes, and putting the old file back would hide that.

@expect: what an assertion may say, and what happens when it cannot

An @expect is a Mendix expression that must evaluate to true. Any expression the Mendix engine accepts works — built-in functions, every comparison operator, and / or / not(...), attribute paths and enumeration values:

/**
 * @test the dealt board is a full grid with blanks
 * @expect length($result) = 81
 * @expect find($result, '0') >= 0
 * @expect substring($result, 0, 9) != substring($result, 9, 18)
 */
$result = CALL MICROFLOW Sudoku.SUB_BlankSquares(Grid = $solved);
/

<> is accepted and rewritten to !=, which is the spelling Mendix’s expression engine accepts — <> fails the build with CE0117.

An assertion the runner cannot compile is an ERROR, not a pass. Unknown functions, wrong arity, unbalanced parentheses, and expressions that produce a value rather than a condition are each reported against the test that carries them, and an ERROR counts with the failures, so the run exits non-zero:

ERROR  a self-evident falsehood
       @expect randomInt($result) = 1: randomInt() is not a Mendix expression
       function at column 1 ("randomInt")

A failing assertion reports what came back, not only what was wanted:

FAIL  the board is 81 squares
      expected length($result) = 81, actual: 27

The observed value is omitted rather than guessed when nothing in the assertion pins down its type (@expect $a = $b, where both sides are variables). Mendix’s expression engine is typed, and a wrong guess would fail the build rather than the test.

A file that does not parse is one ERROR, not a dead run

A malformed test file is reported the same way an uncompilable assertion is: against itself, as an ERROR that counts with the failures. The tests in every other file still run.

  PASS  the board is 81 squares (6ms, 2 assertions)
  ERROR  broken.test.mdl (file could not be parsed)
         test "first" is followed by another @test doc comment ("second") with
         no '/' separator between them

--list prints the same thing under the listing and exits non-zero, so a directory that was only partly readable cannot look like a clean one. A path that does not exist is still a hard error — that is a mistake in the invocation, not a malformed test, and continuing would run a different suite than the one that was asked for.

A test that asserts nothing, and @verify

Every result line reports what the test actually checked:

  PASS  the board is 81 squares (6ms, 2 assertions)
  FAIL  the mix keeps the shared block (8ms, 1 assertion)
         expected length($result) = 81, actual: 27
  PASS  asserts nothing at all (4ms, no assertions)
------------------------------------------------------------
1 test(s) asserted nothing beyond "did not throw". Run with
--require-assertions to make that an error.

A test with no @expect and no @throws is a smoke test — it reports only that the body did not throw. It still passes, because that is a legitimate thing to write; what it may not do is look the same as a test with six assertions. --require-assertions makes every vacuous test an ERROR for projects that want CI to enforce it.

@verify asserts on the database instead of the return value — see below.

The JUnit report (--junit) carries the assertion count as a <property name="assertions"> on each case, and classname/file identify the source test file so a failure in a multi-file run says where it lives.

@verify: asserting on what the microflow wrote

@expect sees only what a microflow returned. Most Mendix microflows are side effects, so @verify is how you assert on the rows one left behind — an OQL query, a comparison, and the value it must satisfy:

/**
 * @test dealing a board writes 81 cells
 * @cleanup none
 * @expect $result = 'ok'
 * @verify select count(*) as n from Sudoku.Cell = 81
 * @verify select count(*) as n from Sudoku.Cell where Value = 0 > 0
 */
$result = CALL MICROFLOW Sudoku.ACT_DealGame();
/

The query runs after the microflow returns, over the same admin API mxcli oql uses. Three rules follow, each enforced rather than left to trip you up:

  • @cleanup none is required. rollback is the default and undoes the test’s writes before the query could see them, so a @verify on a rollback test is refused rather than run against the pre-test state.
  • The query must return exactly one row and one column — aggregate it, or select one attribute of one row. Picking a cell out of a table would be a guess.
  • The expected value is a literal: a number, a quoted string, true/false or empty. It is split off at the last comparison operator outside quotes and parentheses, so a where Value = 5 inside the query is left alone.
  • Every selected column needs a name. Mendix’s OQL rejects a bare select count(*) with “All OQL select columns must have a name” — write select count(*) as n.

Operators are =, != (<> accepted), <, <=, >, >=; numbers compare numerically even though the runtime returns them as strings.

A @verify that cannot be evaluated — unknown entity, malformed OQL, a non-scalar result, or something that was never a query — is an ERROR, never a pass. A false one fails with the value that came back:

FAIL   dealing a board writes 81 cells
       expected select count(*) as n from Sudoku.Cell = 81, actual: 27
ERROR  cells exist for a game that does not
       @verify select count(*) as n from Sudoku.NoSuch = 1: OQL error: Unknown entity

@verify needs the test endpoint, so it works under --local and --attach. The Docker / --legacy-runner path refuses a suite that uses it: its tests run during boot, so there is no point at which to query the app.

The app’s own after-startup microflow

Boot registers the endpoint and then runs the project’s own after-startup microflow, so tests see the app in the state it really boots into. The run prints which of the two happened, and --skip-app-startup opts out when a suite wants an empty, deterministic baseline.

This keeps a suite behaving the same under --local and --attach. Note that what the startup microflow writes is not covered by @cleanup rollback — it runs at boot, outside any test’s transaction.

The generated startup flow, MxTest.RegisterEndpoint, applies entity access, so it can call your after-startup microflow whichever way that one is set: Mendix refuses a microflow that does not apply entity access calling one that does (CE0114), and allows the other direction. Your microflow still runs with its own setting.

The generated test microflows apply entity access for the same reason, so a test, and its @setup microflows, can call a production microflow that applies it without you changing that microflow’s setting. mxcli check sees the test the same way, so it does not report MDL-SEC22 for such a call. Tests run in a system context, which entity access does not restrict, so what a test reads and writes is the same either way.

An applying microflow may not read the attributes of System.Error (CE0150), so the runner’s own error handlers pass $latestError to a generated helper, MxTest.Test_ErrorMessage, which does not apply entity access and returns the message. A test whose own error handler reads $latestError/… keeps a test microflow that does not apply entity access, so it builds as it did; if that test also calls a microflow that applies entity access, no setting serves both, and the handler has to pass $latestError to a microflow of yours that does not apply it, as CE0150’s own text says. Under --legacy-runner the whole suite shares one microflow, so one such test keeps all of them non-applying.

A build error located in MxTest.RegisterEndpoint, MxTest.RegisterTestEndpoint or MxTest.Test_ErrorMessage is reported as mxcli’s, not your project’s — those documents exist only for the run.

@cleanup: what happens to a test’s data

rollback is the default, so a test’s database writes do not survive it. The endpoint opens a transaction around the call and rolls it back afterwards, including when the test throws.

/**
 * @test creating an order does not leak
 * @expect $result = 'ok'
 */
$result = CALL MICROFLOW Sales.CreateOrder(Amount = 100);
/

/**
 * @test seed a fixture the app should keep
 * @cleanup none
 */
$result = CALL MICROFLOW Sales.SeedCatalogue();
/
StrategyEffect
rollback (default)The test’s writes are rolled back, even if it throws
noneThe writes commit and persist

Rollback needs the test endpoint, so it applies to --local and --attach. The Docker / --legacy-runner path runs tests inside the after-startup action and has no context of its own to roll back, so it always commits.

A rollback that fails is reported per test and summarised at the end — data left behind while the suite still says PASS is exactly what this is for. --verbose tags every test [rolled back], [committed] or [ROLLBACK FAILED]. A misspelled strategy is a parse error, not a silent commit.

Docker — the after-startup runner. The whole suite is compiled into the project’s after-startup microflow, the container is restarted, and results are parsed out of its log. --legacy-runner selects this on a local run too.

Both paths restore the project when they finish, and both report loudly if that restore fails — a modified project must never read as a clean pass.

Isolated Testing Pattern

For manual testing or debugging, you can replicate the test runner’s workflow:

# Create a fresh project
cd /tmp/test-workspace
~/.mxcli/mxbuild/<version>/modeler/mx create-project

# Apply MDL changes
mxcli exec script.mdl -p /tmp/test-workspace/App.mpr

# Validate
mxcli docker check -p /tmp/test-workspace/App.mpr

Expected output for a passing test:

Checking app for errors...
The app contains: 0 errors.

Test Validation Checklist

Before marking tests as complete:

  • MDL script executes without errors
  • mx check passes with 0 errors
  • Created elements appear correctly if inspected with DESCRIBE
  • Security rules are valid (no CE0066 errors)

Debugging Test Failures

TypeCacheUnknownTypeException

The type cache does not contain a type with qualified name DomainModels$Index

This means a BSON $Type field uses the wrong name. Check the reflection data for the correct storageName.

CE0066 “Entity access is out of date”

Security access rules don’t match the entity’s current structure. Make sure:

  • All attributes in the entity are included in access rules
  • Association member access is only on the FROM entity
  • Run GRANT after adding new attributes

Common Fixes

# Re-run with verbose output
mxcli exec script.mdl -p app.mpr --verbose

# Inspect the generated BSON
mxcli dump-bson -p app.mpr --doc "Module.EntityName"

Playwright Testing

mxcli integrates with playwright-cli for automated UI testing of Mendix applications. This enables verification that generated pages render correctly, widgets appear in the DOM, and navigation works as expected.

Why Browser Testing?

Mendix apps are React single-page applications. The server returns a JavaScript shell, and the actual UI is rendered client-side. An HTTP 200 from /p/Customer_Overview tells you nothing about whether the page’s widgets actually rendered. A button defined in MDL might be missing from the DOM due to conditional visibility, incorrect container nesting, or a BSON serialization issue – none of which are detectable without executing JavaScript in a real browser.

Prerequisites

mxcli init provisions everything below inside the generated devcontainer, so in a normal project you do not install anything manually:

  • Node.js (LTS) – via the base image; required for playwright-cli.
  • playwright-cli – installed globally and pinned to a known-good version (npm install -g @playwright/cli@0.1.15). It is deliberately not @latest: the package is young and its CLI surface shifts between releases.
  • Chromium (headless shell) – installed via @playwright/cli’s bundled playwright-core into a shared PLAYWRIGHT_BROWSERS_PATH, and exposed at the stable path /usr/local/bin/mx-headless-shell. The generated .playwright/cli.config.json pins executablePath to that symlink.
  • Running Mendix app – start with mxcli docker run -p app.mpr --wait.

mxcli init also generates .playwright/cli.config.json (headless Chromium, timeouts, allowed origins) and sets PLAYWRIGHT_CLI_SESSION=mendix-app in the devcontainer so the runner and your scripts share one browser session.

Manual / non-devcontainer setup (Linux arm64 gotchas)

If you provision outside mxcli init or debug a browser-launch failure:

  • playwright-cli install initializes the workspace – it does not install a browser (the browser command is playwright-cli install-browser).

  • open --browser only accepts chrome | firefox | webkit | msedge (no chromium), and defaults to the chrome channel, which has no distribution on Linux arm64. Use the bundled Chromium instead and pin it explicitly:

    node "$(npm root -g)/@playwright/cli/node_modules/playwright-core/cli.js" \
      install chromium chromium-headless-shell
    

    Then point .playwright/cli.config.json at the headless-shell binary (headless mode needs the chromium_headless_shell-* build, not the full chromium-* one):

    "browser": {
      "browserName": "chromium",
      "launchOptions": {
        "headless": true,
        "executablePath": "/usr/local/bin/mx-headless-shell"
      }
    }
    

mxcli playwright check

The first question about a page is usually textual — does it render, is there an error, did the list get rows — and mxcli playwright check answers it in one call against an app that is already running, with no screenshot to read:

mxcli playwright check /p/Customer_Overview /p/Order_Overview -p app.mpr
page /p/Customer_Overview  title="Customers"  h="Customer overview"  rows=12  text=812  console-errors=0
page /p/Order_Overview  title="Orders"  text=254  console-errors=1
  ALERT  An error occurred, please contact your system administrator.
  ERR    [Client] An error occurred while executing microflow data source for widget …
  HTTP   560 POST /xas/
  FAIL   1 error banner(s)
  FAIL   1 console error(s)
  FAIL   1 failed request(s)
FAIL 1 of 2 page(s)

The verdict line is the same one mxcli run --local --page-check prints; both use one probe, and every page of a call is loaded in one headless browser.

A page is measured once it has settled: no request to the app open for half a second, no progress indicator, and its text unchanged. The requests are the part that matters. While a list view’s data source runs, the list view shows nothing that says so — no spinner, no “no items” placeholder — so a page caught then looks finished and simply empty (ako/mxcli#845). A page that has not settled within --wait (default 15s; a page that settles sooner is measured then) is reported as still loading and fails, naming what it was waiting for:

page /p/Order_Overview  title="Orders"  STILL LOADING  rows=0  text=37  console-errors=0
  WAIT   not settled after 3.1s: still waiting for POST /xas/
  FAIL   still loading when measured, so the verdict may describe a partly rendered page (raise --wait if it is only slow)

The sign-in form is not waited for: the client keeps a request open behind it, so it would otherwise run out the whole --wait and then read as still loading. Once the form holds still it is measured, and it fails as the sign-in form.

A page with a list view, data grid or gallery always reports rows=, rows=0 included, so an empty list does not read the same as a page without one.

When the browser ends up somewhere other than the URL it was sent to, the verdict says where, as url=, right after the page — the title and heading that follow describe the page at that URL. A /p/ URL that ends anywhere else fails: the runtime answers every /p/<x> with a redirect to /#/<x>, and the client sends one it has no page for to the home page, with HTTP 200 throughout (ako/mxcli#998). The match is exact, so /p/Items and /p/items/ fail where /p/items passes.

page /p/doesnotexist  url=/  title="Mendix - Homepage"  h="Welcome to your homepage"  text=1925  console-errors=0
  ALERT  The page you requested was not found. You are redirected to the home page.
  FAIL   the browser ended on /, not on /p/doesnotexist: the client sends a /p/ URL it has no page for to the home page

A page that ends on the sign-in form (url=/login.html#/items) fails for that reason only.

A page the signed-in user’s roles may not open ends on the sign-in form too, but with messageCode=403 in the URL: the runtime refuses the client’s session request for that page with 403, and the client sends the browser to /login.html?messageCode=403#/<page>. The form shows no message, so the URL is the only thing that tells it from “not signed in”. It fails as access denied, naming who the check signed in as; signing in again would get the same answer, so the check does not:

page /p/secret  url=/login.html?messageCode=403#/secret  ACCESS DENIED  title="Login"  h="Sign in"  text=34  console-errors=1
  ERR    [Startup] An error occurred while creating a new session. i_: Internal server error …
  HTTP   403 POST /xas/
  FAIL   access denied: signed in as demo_user (user role User), the runtime refused /p/secret (403)

Check the page’s allowed module roles (mxcli -p app.mpr -c "show access on page Module.Page") against the user role’s (describe user role R).

Exit statusMeaning
0every page passed
1a page failed: still loading when measured, HTTP error, access denied to the signed-in user (403), sign-in form instead of the page (or a 401 from the runtime), a /p/ URL that ended elsewhere (no page has that URL), error banner or error dialog, console error, failed same-origin request, or a failed --assert-*
2the check could not run (no Node/Playwright, sign-in rejected, bad flag)

A warning banner or validation message is shown but does not fail the page.

An app on this machine, by its own host name, behind a proxy

An app on this machine is reached directly, whatever name the URL gives it. This matters where the environment sets a proxy — a dev container, a Claude Code session — and the app is checked by a host name of its own, such as an /etc/hosts entry frontend.local for 127.0.0.1 (the usual way to give two local apps separate cookie jars). Chromium takes its proxy from http_proxy / https_proxy, and for a WebSocket it prefers the HTTPS proxy even for ws://. With only https_proxy set and no_proxy listing localhost and 127.0.0.1, the page loaded directly while the runtime’s dev channel, ws://frontend.local:8180/mxdevtools/, went to the proxy, which cannot reach this machine and answered 502. A page that rendered failed on two console errors (ako/mxcli#1800):

  ERR    WebSocket connection to 'ws://frontend.local:8180/mxdevtools/' failed: Error during WebSocket handshake: Unexpected response code: 502

The browser that playwright check, run --page-check and run --screenshot start now adds a host that resolves only to this machine (loopback, or an address of one of its interfaces) to no_proxy. Nothing is ignored: the channel connects, as it does on 127.0.0.1, and a console error the page does have still fails it. A remote URL, such as a --hub preview, still goes through the proxy. A browser of your own needs the same entry in no_proxy.

Signing in

FlagsSigns in as
--user U --password Pthat user
--role R -p app.mprthe project’s demo user holding user role R
-p app.mpr onlya demo user of the project (an administrator first), when demo users are enabled
nonenobody; a secured page fails with “the sign-in form is showing”

The sign-in happens on the host of the pages checked, not on --base-url: a session’s cookies belong to the host they were set on, so one signed in on localhost is not sent to 127.0.0.1 or to another name for the same app. A page given as a full URL on another host (mxcli playwright check http://frontend.local:8180/ -p app.mpr) gets a session of its own; before ako/mxcli#1216 it was checked with the base URL’s session, showed the sign-in form on every run, and signed in again on each.

The session is saved as a Playwright storage state under <project>/.mxcli/playwright-check/<host>-<user>.json (or ~/.mxcli/playwright-check/ without -p) and reused by the next check, so repeated checks do not sign in again — on the unlicensed runtime every sign-in takes one of a handful of session slots, and a reused session stays one session however many checks run. When the runtime no longer knows the session (it restarted), the check signs in once more and reloads only the affected pages.

  • --fresh-login signs the saved session out, then signs in again.
  • --sign-out ends the run signed out and keeps nothing for the next check — for a last check in a script that should leave no session behind.
  • A session the run signed in that got no page past the sign-in form is signed out rather than kept: kept, every later check would sign in again beside it. A page the user’s roles may not open does not count — the runtime knew the session and refused the page — so that session is kept and reused, and is not renewed either.

Signing out is best effort and never changes the verdict; a sign-out that does not complete is reported on stderr, and that session expires on its own. When the runtime refuses a sign-in, the check reads <project>/.mxcli/runtime.log for the cause — the unlicensed runtime’s session cap shows on the sign-in page only as “Sign in failed”.

The sign-in types into the Mendix login form, so the “setting .value leaves the field empty” problem of hand-written eval logins does not arise. When the app root lets anonymous users in, it signs in at /login.html.

Assertions and screenshots

mxcli playwright check /p/Customer_Overview -p app.mpr \
  --assert-text 'Customer overview' \
  --assert-count '.mx-name-dgCustomers [role=row]:has([role=gridcell])>=1'
  • --assert-text T — the page’s visible text contains T.
  • --assert-count 'SELECTOR OP N' — OP is one of >= <= == != > < (= means ==); the operator is read from the right, so a selector can use > as a child combinator. A bare selector means “at least one”.
  • Both are repeatable and apply to every page in the call.
  • --screenshot out.png also writes a full-page PNG (with several pages, one per page: out-p-Customer_Overview.png, …) and prints only its path. Take one when the question is about appearance, not to find out whether the page works.

Options

FlagDefaultDescription
--base-urlhttp://localhost:8080App root; with -p, the port of a live mxcli run --local for the project, else APP_PORT from .docker/.env
--user, --passwordSign in as this user
--roleSign in as the project’s demo user with this user role (needs -p)
--assert-textText the page must contain (repeatable)
--assert-countSELECTOR OP N that must hold (repeatable)
--screenshotAlso write a PNG here; the path is printed
--wait15sLongest a page may keep loading after it mounts (open requests, changing text) before it is measured; the sign-in form is measured as soon as it holds still
--fresh-loginfalseSign the saved session out and sign in again
--sign-outfalseSign out when the check is done instead of keeping the session
--allow-console-errorsfalseReport console errors without failing on them

The check needs Node and the Playwright package (require('playwright') or the playwright-core bundled with @playwright/cli, both resolved from the global node_modules). Without a node on PATH, the one bundled with a cached MxBuild is used. The browser is /usr/local/bin/mx-headless-shell when present (the devcontainer), $MXCLI_CHROMIUM when set, otherwise Playwright’s own.

mxcli playwright verify

Use verify when the test needs an interaction — clicking, filling, submitting. The mxcli playwright verify command runs .test.sh scripts against a running Mendix application and collects results.

# Run all test scripts in a directory
mxcli playwright verify tests/ -p app.mpr

# Run a specific script
mxcli playwright verify tests/verify-customers.test.sh

# List discovered scripts without executing
mxcli playwright verify tests/ --list

# Output JUnit XML for CI integration
mxcli playwright verify tests/ -p app.mpr --junit results.xml

# Verbose output (show script stdout/stderr)
mxcli playwright verify tests/ -p app.mpr --verbose

# Custom app URL (auto-detected from .docker/.env by default)
mxcli playwright verify tests/ --base-url http://localhost:9090

# Skip the app health check
mxcli playwright verify tests/ --skip-health-check

Options

FlagDefaultDescription
--list, -lfalseList test scripts without executing
--junit, -jWrite JUnit XML results to file
--verbose, -vfalseShow script stdout/stderr during execution
--colorfalseUse colored terminal output
--timeout, -t2mTimeout per script execution
--base-urlhttp://localhost:8080Mendix app base URL
--skip-health-checkfalseSkip app reachability check before running
--keep-openfalseLeave the browser session open after the run so the next verify reuses it
-pPath to the .mpr project file

How It Works

The verify runner performs these steps:

  1. Discovers all .test.sh files in the provided paths
  2. Checks that playwright-cli is available in PATH
  3. Health-checks the app at the base URL (unless --skip-health-check)
  4. Reuses a live browser session if one is already open on the same origin (left by a prior --keep-open run), re-navigating to the base URL so a rebuilt app loads fresh; otherwise opens a new one (Chromium by default)
  5. Runs each .test.sh script sequentially via bash
  6. Captures a screenshot on failure for debugging
  7. Closes the browser session – unless --keep-open was passed, in which case it is left warm
  8. Reports pass/fail per script with timing
  9. Writes JUnit XML if --junit is specified
  10. Exits with non-zero status if any script failed

Reusing the session across runs (the iterate loop)

The generate → build → verify → fix loop re-runs verify after each change. Passing --keep-open leaves the browser warm and logged in, and the next run detects that live session and skips the ~1–2s Chromium cold start:

# First run opens the browser (log in once inside a script or via state-save)
mxcli playwright verify tests/ -p app.mpr --keep-open

# ... edit MDL, mxcli exec, mxcli docker run ...

# Subsequent runs reuse the warm, still-authenticated session
mxcli playwright verify tests/ -p app.mpr --keep-open

Reuse only happens when a session is alive and on the target origin; if not, verify opens fresh, so there is no downside to leaving --keep-open on. On reuse the runner re-navigates to the base URL, so a rebuilt app is always loaded fresh (no risk of verifying a stale, pre-rebuild page). For CI regression runs, omit --keep-open so the browser is torn down at the end.

Note: if a script ends with its own playwright-cli close, it tears down the shared session regardless of --keep-open — drop the trailing close from scripts you want to reuse across runs.

Session lifecycle commands

For interactive or agent-driven use, manage the browser session explicitly instead of only through verify:

# Open (or attach to) the session; URL from the arg, else --base-url,
# else APP_PORT in .docker/.env, else :8080
mxcli playwright open -p app.mpr
mxcli playwright open http://localhost:9090

# Is a session live, and what page is it on?
mxcli playwright status

# Tear it down
mxcli playwright close
mxcli playwright close --all   # close every session

open uses the same open-or-reuse behavior as verify (attach to a live same-origin session and re-navigate, or open fresh). A typical agent loop is: open once, log in, then iterate verify --keep-open — and status to check whether the browser is still up before deciding to reopen.

eval vs run-code – read this first

@playwright/cli has two evaluation commands with different execution contexts. Getting this wrong is the most common scripting mistake:

CommandRuns inUse for
playwright-cli eval "() => ..."browser page (document, window exist)DOM assertions, clicks, filling fields, reading .mx-name-*
playwright-cli run-code "..."Node (Playwright API; document is undefined)Playwright-level scripting, not page DOM

eval takes a function ("() => ...") and prints its return value under ### Result; it awaits a returned Promise. Do not use run-code "document.querySelector(...)" – it throws ReferenceError: document is not defined. Every page/DOM assertion below uses eval.

Test Script Format

Test scripts are plain bash files using playwright-cli commands. The naming convention is tests/verify-<name>.test.sh. Scripts should use set -euo pipefail so that any failing command causes the script to exit with a non-zero code.

Example Test Script

#!/usr/bin/env bash
# tests/verify-customers.test.sh -- Customer module smoke test
set -euo pipefail

# --- Login (page context -> eval) ---
playwright-cli open http://localhost:8080
playwright-cli eval "() => { document.querySelector('#usernameInput').value = 'MxAdmin' }"
playwright-cli eval "() => { document.querySelector('#passwordInput').value = 'AdminPassword1!' }"
playwright-cli eval "() => document.querySelector('#loginButton').click()"
playwright-cli eval "() => new Promise(r => setTimeout(r, 3000))"
playwright-cli state-save mendix-auth

# --- Customer Overview page (throw to fail the script under set -e) ---
playwright-cli goto http://localhost:8080/p/Customer_Overview
playwright-cli eval "() => { if (!document.querySelector('.mx-name-dgCustomers')) throw new Error('dgCustomers not found') }"
playwright-cli eval "() => { if (!document.querySelector('.mx-name-btnNew')) throw new Error('btnNew not found') }"
playwright-cli eval "() => { if (!document.querySelector('.mx-name-btnEdit')) throw new Error('btnEdit not found') }"

# --- Create a customer ---
playwright-cli eval "() => document.querySelector('.mx-name-btnNew').click()"
playwright-cli eval "() => new Promise(r => setTimeout(r, 2000))"
playwright-cli fill txtName "CI Test Customer"
playwright-cli fill txtEmail "ci@test.com"
playwright-cli eval "() => document.querySelector('.mx-name-btnSave').click()"
playwright-cli eval "() => new Promise(r => setTimeout(r, 2000))"

# --- Verify persistence (via OQL, not direct DB query) ---
mxcli oql -p app.mpr --json \
  "SELECT Name FROM MyModule.Customer WHERE Name = 'CI Test Customer'" \
  | grep -q "CI Test Customer"

# --- Cleanup ---
playwright-cli close
echo "PASS: verify-customers"

Widget Selectors

Mendix renders each widget’s name property as a CSS class on the corresponding DOM element:

<div class="mx-name-submitButton form-group">

These .mx-name-* classes are stable, predictable, and directly derived from MDL widget names. This makes them ideal for test assertions.

Using Selectors in Scripts

The recommended approach for CI scripts is to use eval (page context) with CSS selectors, since dynamic element refs (e12, e15) change between page loads:

# Check widget presence
playwright-cli eval "() => document.querySelector('.mx-name-btnSave') !== null"

# Click a widget
playwright-cli eval "() => document.querySelector('.mx-name-btnSave').click()"

# Check text content
playwright-cli eval "() => document.querySelector('.mx-name-lblTitle').textContent.includes('Customers')"

For assertions that should cause failures, throw inside the function so the script exits non-zero under set -e:

playwright-cli eval "() => { if (!document.querySelector('.mx-name-btnSave')) throw new Error('btnSave not found') }"

Three Test Layers

Layer 1: Smoke Tests

Fast checks that pages are reachable and the app starts without errors. These run first as a gate before heavier tests.

# HTTP reachability
playwright-cli open http://localhost:8080
playwright-cli goto http://localhost:8080/p/Customer_Overview
playwright-cli goto http://localhost:8080/p/Customer_Edit

Layer 2: UI Widget Tests

Verify that every widget generated in MDL is present and interactive in the DOM.

# Widget presence
playwright-cli eval "() => document.querySelector('.mx-name-dgCustomers') !== null"
playwright-cli eval "() => document.querySelector('.mx-name-btnNew') !== null"

# Form interaction (dispatch an input event so Mendix registers the change)
playwright-cli eval "() => { const el = document.querySelector('.mx-name-txtName input'); el.value = 'Test'; el.dispatchEvent(new Event('input', {bubbles: true})) }"
playwright-cli eval "() => document.querySelector('.mx-name-btnSave').click()"

Layer 3: Data Assertions

Verify that UI interactions persist the correct data. Use mxcli oql for data validation instead of direct database queries.

# Submit a form via the UI, then verify persistence
mxcli oql -p app.mpr --json \
  "SELECT Name, Email FROM MyModule.Customer WHERE Name = 'Test'" \
  | grep -q "Test"

Example Output

Found 3 test script(s)
Checking app at http://localhost:8080...
  App is reachable
Opening browser session (chromium)...
  [1/3] verify-login... PASS (1.2s)
  [2/3] verify-customers... PASS (3.4s)
  [3/3] verify-orders... FAIL (2.1s)
         Screenshot saved: verify-orders-failure.png
         Error: btnSubmit not found

Playwright Verify Results
============================================================
  PASS  verify-login (1.2s)
  PASS  verify-customers (3.4s)
  FAIL  verify-orders (2.1s)
         Error: btnSubmit not found
------------------------------------------------------------
Total: 3  Passed: 2  Failed: 1  Time: 6.7s
Some scripts failed.

CI/CD Integration

For continuous integration, combine mxcli docker run --wait with mxcli playwright verify:

# Build and start the app
mxcli docker run -p app.mpr --wait

# Run all verification scripts
mxcli playwright verify tests/ -p app.mpr --junit results.xml

# JUnit XML output is consumed by CI systems (GitHub Actions, Jenkins, etc.)
  • Testing – MDL test framework (mxcli test)
  • Docker Run – Building and running the Mendix app in Docker
  • Docker Check – Validating projects without building

Diff

mxcli provides two diff commands for comparing MDL scripts against project state and viewing local changes in MPR v2 projects.

mxcli diff

Shows what mxcli exec would change in the project if it ran the script, without changing the project.

Usage:

mxcli diff -p app.mpr changes.mdl

How it works: diff runs exec

diff copies the project to a scratch folder, runs the script there with exec’s own code — the same pre-flight checks, the statements in order, under the script’s language header — and then compares the copy with the project unit by unit. What it reports is therefore exactly what exec would write: there is no second judgement to disagree with exec’s. The project itself is only read, and the scratch copy is deleted afterwards (it leaves out .git, deployment, releases and node_modules).

Each unit exec would add, rewrite, move or remove is shown as the describe of the document before and after. A domain model is shown per entity and association, module security per module role, and project security per user role and demo user. This covers every document kind — pages, snippets, layouts, navigation, settings, security, folders — and statements of every kind, including grant, alter, move and drop.

--- Page.MyFirstModule.Home_Web (current)
+++ Page.MyFirstModule.Home_Web (script)
@@ -1,6 +1,6 @@
 create or modify page MyFirstModule.Home_Web (
   Title: 'Homepage',
-  Layout: Atlas_Core.Atlas_TopBar,
+  Layout: Atlas_Core.Atlas_Default,
   PopupResizable: true
 ) {

Summary: 0 new, 1 modified, 0 removed — exec would write 1 unit(s)

A unit exec rewrites although its description does not change — a property describe does not print, a translation, a move to another folder — is listed on one line with what changes, so a write is never hidden behind identical MDL:

Modified: Page MyFirstModule.Home_Web: changed: CanvasHeight, CanvasWidth
Modified: Microflow Shop.ACT_Apply: moved to 'Shop/Archive'

Files exec writes next to the model (Java sources, theme files) are listed as New file: / Modified file:.

What diff no longer reports

Because the verdict is exec’s own, a script exec has already applied diffs as exec would write nothing. Before (ako/mxcli#907), diff compared the script’s text with the stored document’s description and reported phantom changes exec never makes — Boolean against the stored Boolean default false, String against String(unlimited), a position, a re-laid-out flow — and did not compare pages, translations or layouts at all.

Refusals and errors

  • A script exec’s pre-flight refuses (a check error, an unresolved reference, a name clash) is reported as Refused, with the same report exec prints, and nothing is written. --no-check skips the pre-flight, as it does for exec.
  • A statement exec stops at — a plain create of a document that exists (ako/mxcli#807), a create or modify of a flow the splice cannot make under mdl 1; — is reported as Refused with exec’s error, after the changes exec makes before it. --continue-on-error runs every statement, as it does for exec.
  • Statements that depend on earlier ones (a flow calling a microflow the script creates first) are diffed like any other, because the earlier ones run first (ako/mxcli#856).
Refused: exec would stop at this error, having written nothing: entity already exists: Shop.Order — …

Summary: 0 new, 0 modified, 0 removed — exec would write nothing

diff always runs the script with the file engine on the scratch copy, also under --mcp: the script is executed for real, and only the copy may receive it. --exec-output prints what exec reports while it runs.

For the same reason a script may not reach past the copy. A connect to the -p project (a headerless script may hold one, and so may a script it runs with execute script) is followed on the copy; a connect to any other project, a sql <alias> <query> and an import from are refused with an error, because diff has no copy of that project or database and does not run them for real.

mxcli diff-local

Compares local changes against a git reference for MPR v2 projects. MPR v2 (Mendix >= 10.18) stores documents as individual files in an mprcontents/ folder, making git diff feasible.

Usage:

# Compare against HEAD (latest commit)
mxcli diff-local -p app.mpr --ref HEAD

# Compare against a specific commit
mxcli diff-local -p app.mpr --ref HEAD~1

# Compare against a branch
mxcli diff-local -p app.mpr --ref main

# Compare two arbitrary revisions (git range syntax)
mxcli diff-local -p app.mpr --ref main..feature-branch

# Three-dot range (changes since common ancestor)
mxcli diff-local -p app.mpr --ref main...feature-branch

With three dots the old side is the merge base of the two revisions, labelled (merge base of main and feature-branch), so the diff shows only what the branch changed. With two dots it is main’s tip, so anything main changed since the branch split also shows up, reversed. An empty side means HEAD, as it does to git (--ref main.. is main..HEAD).

What diff-local shows

Each changed .mxunit is rendered as MDL at both revisions and the two renderings are diffed. Domain models, microflows, nanoflows, pages, snippets, layouts, enumerations, modules and the project settings render as MDL — a domain model’s entity access rules as the grant statements describe entity prints, the settings as describe settings prints them, except that configuration constant values are masked, since they often hold secrets. A changed value still shows, as value <masked: old value> against value <masked: new value>.

A unit that changed only in properties its rendering leaves out — navigation, project or module security, a page’s allowed roles, an entity index — is printed as one line naming the properties that changed, never their values, rather than counted in the summary and left blank:

Modified: ModuleSecurity MyFirstModule: changed in properties the MDL rendering does not show: ModuleRoles (1 -> 2 items)

MPR v2 Requirement

diff-local only works with MPR v2 format (Mendix >= 10.18), where documents are stored as individual files. MPR v1 projects store everything in a single SQLite database, making file-level git diff impractical.

Workflow

Review Before Applying

# 1. Generate MDL changes
# (AI assistant creates changes.mdl)

# 2. Review what would change
mxcli diff -p app.mpr changes.mdl

# 3. If satisfied, apply
mxcli exec changes.mdl -p app.mpr

Track Changes Over Time

# After making changes, see what changed since last commit
mxcli diff-local -p app.mpr --ref HEAD

# See changes since two commits ago
mxcli diff-local -p app.mpr --ref HEAD~2

Compare Branches

# What changed between main and your feature branch
mxcli diff-local -p app.mpr --ref main..feature-branch

# Feed diff into an LLM for review
mxcli diff-local -p app.mpr --ref main..feature-branch > changes.diff

External SQL

mxcli can connect to external databases (PostgreSQL, Oracle, SQL Server) for querying, schema exploration, data import, and Database Connector generation. This enables workflows like importing reference data from external systems, exploring database schemas, and auto-generating Mendix integration code.

Supported Databases

DatabaseDriver NamesDSN Format
PostgreSQLpostgres, pg, postgresqlpostgres://user:pass@host:5432/dbname
Oracleoracle, oraoracle://user:pass@host:1521/service
SQL Serversqlserver, mssqlsqlserver://user:pass@host:1433?database=dbname

Security

Credentials are isolated from session output. The DSN (which contains username and password) is never displayed in session output, logs, or error messages. Only the alias and driver name are shown.

Capabilities

FeatureCommandDescription
ConnectSQL CONNECTEstablish a named connection
ExploreSQL <alias> SHOW TABLES/VIEWSBrowse database schema
DescribeSQL <alias> DESCRIBE <table>View column details
QuerySQL <alias> <any-sql>Run arbitrary SQL
ImportIMPORT FROM <alias>Import data into Mendix app DB
GenerateSQL <alias> GENERATE CONNECTORAuto-generate Database Connector MDL
DisconnectSQL DISCONNECT <alias>Close connection

Quick Start

-- Connect to an external database
SQL CONNECT postgres 'postgres://user:pass@localhost:5432/mydb' AS source;

-- Explore the schema
SQL source SHOW TABLES;
SQL source DESCRIBE employees;

-- Query data
SQL source SELECT * FROM employees WHERE active = true LIMIT 10;

-- Import into Mendix
IMPORT FROM source QUERY 'SELECT name, email FROM employees'
  INTO HR.Employee
  MAP (name AS Name, email AS Email);

-- Generate Database Connector MDL
SQL source GENERATE CONNECTOR INTO HRModule TABLES (employees, departments) EXEC;

-- Disconnect
SQL DISCONNECT source;

CLI Subcommand

For one-off queries without the REPL:

mxcli sql --driver postgres --dsn 'postgres://user:pass@localhost:5432/mydb' "SELECT * FROM users"

SQL CONNECT

The SQL CONNECT command establishes a named connection to an external database.

Syntax

SQL CONNECT <driver> '<dsn>' AS <alias>;

Parameters:

ParameterDescription
<driver>Database driver name
<dsn>Data Source Name (connection string)
<alias>Short name to reference this connection

Supported Drivers

DatabaseDriver Names
PostgreSQLpostgres, pg, postgresql
Oracleoracle, ora
SQL Serversqlserver, mssql

Connection Examples

PostgreSQL

SQL CONNECT postgres 'postgres://user:password@localhost:5432/mydb' AS source;

-- With SSL
SQL CONNECT postgres 'postgres://user:password@host:5432/mydb?sslmode=require' AS prod;

Oracle

SQL CONNECT oracle 'oracle://user:password@host:1521/service_name' AS oradb;

SQL Server

SQL CONNECT sqlserver 'sqlserver://user:password@host:1433?database=mydb' AS mssql;

Managing Connections

List Active Connections

SQL CONNECTIONS;

This shows alias and driver name only. The DSN is never displayed for security reasons.

Disconnect

SQL DISCONNECT source;

Multiple Connections

You can maintain multiple simultaneous connections with different aliases:

SQL CONNECT postgres 'postgres://...' AS source_db;
SQL CONNECT oracle 'oracle://...' AS legacy_db;
SQL CONNECT sqlserver 'sqlserver://...' AS reporting_db;

-- Query each by alias
SQL source_db SELECT count(*) FROM users;
SQL legacy_db SELECT count(*) FROM employees;
SQL reporting_db SELECT count(*) FROM reports;

-- List all connections
SQL CONNECTIONS;

-- Disconnect individually
SQL DISCONNECT legacy_db;

CLI Subcommand

For one-off queries without establishing a named connection:

mxcli sql --driver postgres --dsn 'postgres://user:pass@localhost:5432/mydb' "SELECT 1"

Security Notes

  • The DSN (which contains credentials) is never shown in session output or logs
  • Only the alias and driver type are displayed when listing connections
  • See Credential Management for environment variable and YAML config alternatives

Querying External Databases

Once connected to an external database, you can explore its schema and run arbitrary SQL queries.

Schema Exploration

Show Tables

SQL <alias> SHOW TABLES;

Lists all user tables in the connected database.

Show Views

SQL <alias> SHOW VIEWS;

Lists all user views.

Show Functions

SQL <alias> SHOW FUNCTIONS;

Lists functions and stored procedures.

Describe Table

SQL <alias> DESCRIBE <table>;

Shows column details including name, type, and nullability.

Running Queries

Any SQL statement can be passed through to the connected database:

SQL <alias> <any-sql>;

Examples

-- Connect
SQL CONNECT postgres 'postgres://user:pass@localhost:5432/mydb' AS source;

-- Explore schema
SQL source SHOW TABLES;
SQL source DESCRIBE employees;

-- Simple queries
SQL source SELECT * FROM employees WHERE active = true LIMIT 10;

-- Aggregation
SQL source SELECT department, COUNT(*) as cnt FROM employees GROUP BY department ORDER BY cnt DESC;

-- Joins
SQL source SELECT e.name, d.name AS department
  FROM employees e
  JOIN departments d ON e.dept_id = d.id
  WHERE e.active = true;

-- DDL (if you have permissions)
SQL source CREATE INDEX idx_employees_email ON employees(email);

CLI Subcommand

For one-off queries from the command line:

# Direct query
mxcli sql --driver postgres --dsn 'postgres://user:pass@localhost:5432/mydb' "SELECT * FROM users LIMIT 5"

Interactive Workflow

Within the REPL, use SQL commands interactively:

-- Connect
SQL CONNECT postgres 'postgres://user:pass@localhost:5432/mydb' AS source;

-- Explore
SQL source SHOW TABLES;
SQL source DESCRIBE users;

-- Query
SQL source SELECT id, name, email FROM users WHERE created_at > '2024-01-01' LIMIT 10;

-- Done
SQL DISCONNECT source;

Output

Query results are displayed in a formatted table by default. The output format can be controlled with session variables:

SET output_format = 'json';
SQL source SELECT * FROM users LIMIT 5;

IMPORT FROM

The IMPORT command imports data from an external database into a Mendix application’s PostgreSQL database. It supports column mapping, association linking, batch processing, and row limits.

Syntax

IMPORT FROM <alias> QUERY '<sql>'
  INTO <Module.Entity>
  MAP (<source-col> AS <AttrName> [, ...])
  [LINK (<source-col> TO <AssocName> ON <MatchAttr>) [, ...]]
  [BATCH <size>]
  [LIMIT <count>]

Parameters

ParameterDescription
<alias>Named connection established with SQL CONNECT
QUERY '<sql>'SQL query to execute against the external database
INTO <Module.Entity>Target Mendix entity to insert into
MAP (...)Column-to-attribute mapping
LINK (...)Association linking via lookup
BATCH <size>Number of rows per batch insert (default varies)
LIMIT <count>Maximum number of rows to import

Basic Import

Map source columns to entity attributes:

SQL CONNECT postgres 'postgres://user:pass@localhost:5432/mydb' AS source;

IMPORT FROM source QUERY 'SELECT name, email FROM employees'
  INTO HR.Employee
  MAP (name AS Name, email AS Email);

Import with Association Linking

The LINK clause creates associations by looking up related entities:

IMPORT FROM source QUERY 'SELECT name, dept_name FROM employees'
  INTO HR.Employee
  MAP (name AS Name)
  LINK (dept_name TO Employee_Department ON Name);

This matches the dept_name value from the source query against the Name attribute of the entity on the other side of the Employee_Department association, creating the association link for each imported row.

Batch and Limit Control

IMPORT FROM source QUERY 'SELECT * FROM products'
  INTO Catalog.Product
  MAP (name AS Name, price AS Price)
  BATCH 500
  LIMIT 1000;
  • BATCH 500 – Insert 500 rows at a time (reduces memory usage)
  • LIMIT 1000 – Stop after importing 1000 rows

How It Works

The IMPORT pipeline:

  1. Executes the source query against the external database
  2. Generates Mendix IDs for each new row (proper Mendix UUID format)
  3. Maps columns to entity attributes based on the MAP clause
  4. Resolves associations by looking up matching entities for LINK clauses
  5. Batch inserts into the Mendix application’s PostgreSQL database
  6. Tracks sequences to ensure auto-number fields continue correctly

Auto-Connection to Mendix DB

The IMPORT command automatically connects to the Mendix application’s PostgreSQL database based on the project’s configuration settings. You do not need to manually connect to the Mendix database.

Complete Example

-- Connect to external source
SQL CONNECT postgres 'postgres://user:pass@legacy-db:5432/hrdb' AS legacy;

-- Preview the data
SQL legacy SELECT name, email, department FROM employees LIMIT 5;

-- Import employees
IMPORT FROM legacy QUERY 'SELECT name, email FROM employees WHERE active = true'
  INTO HR.Employee
  MAP (name AS FullName, email AS EmailAddress)
  BATCH 500;

-- Import with association linking
IMPORT FROM legacy QUERY 'SELECT name, dept_name FROM employees WHERE active = true'
  INTO HR.Employee
  MAP (name AS FullName)
  LINK (dept_name TO Employee_Department ON DepartmentName)
  BATCH 500
  LIMIT 10000;

-- Disconnect
SQL DISCONNECT legacy;

Notes

  • The target entity must already exist in the Mendix project
  • Column types are automatically converted where possible
  • Association linking requires the target entity to have matching values in the lookup attribute
  • The Mendix application database must be a PostgreSQL instance accessible from the mxcli host

Credential Management

mxcli provides secure credential management for external database connections. Credentials are isolated from session output, logs, and error messages.

Security Principles

  • DSN isolation – Connection strings (which contain passwords) are never displayed in session output
  • Alias-only display – SQL CONNECTIONS only shows alias and driver type
  • Log sanitization – Credentials are stripped from log output and error messages

Connection Methods

Inline DSN

The simplest approach, suitable for development:

SQL CONNECT postgres 'postgres://user:password@localhost:5432/mydb' AS source;

Environment Variables

Store credentials in environment variables for CI/CD and production:

# Set environment variable
export MYDB_DSN='postgres://user:password@host:5432/mydb'

# Use in mxcli (depends on your shell expanding the variable)
SQL CONNECT postgres '$MYDB_DSN' AS source;

YAML Configuration

mxcli supports YAML configuration files for managing multiple database connections. The configuration file stores DSN information that can be referenced by name.

Configuration is resolved from:

  1. Environment variables
  2. YAML config files
  3. Inline DSN strings

CLI Subcommand

The mxcli sql subcommand accepts credentials via command-line flags:

mxcli sql --driver postgres --dsn 'postgres://user:pass@host:5432/db' "SELECT 1"

For CI/CD pipelines, use environment variables:

export DB_DSN='postgres://user:pass@host:5432/db'
mxcli sql --driver postgres --dsn "$DB_DSN" "SELECT * FROM users"

Best Practices

  1. Never commit credentials to version control
  2. Use environment variables in CI/CD pipelines
  3. Use YAML config for local development with multiple databases
  4. Rotate credentials regularly
  5. Use read-only database users when only querying (not importing)

Mendix Application Database

For IMPORT commands that write to the Mendix application database, the connection is automatically established using the project’s configuration settings (from DESCRIBE SETTINGS). The Mendix database DSN is built from the project’s DatabaseType, DatabaseUrl, DatabaseName, DatabaseUserName, and DatabasePassword settings.

Database Connector Generation

The SQL GENERATE CONNECTOR command auto-generates Mendix Database Connector MDL from an external database schema. This creates the entities, microflows, and configuration needed to query external databases from within a Mendix application.

Syntax

SQL <alias> GENERATE CONNECTOR INTO <module> [TABLES (<table1>, <table2>, ...)] [VIEWS (<view1>, ...)] [EXEC]

Parameters

ParameterDescription
<alias>Named connection established with SQL CONNECT
INTO <module>Target Mendix module to generate the connector in
TABLES (...)Optional: specific tables to include (default: all)
VIEWS (...)Optional: specific views to include
EXECExecute the generated MDL immediately (otherwise just output it)

Examples

Generate for All Tables

SQL CONNECT postgres 'postgres://user:pass@localhost:5432/mydb' AS source;

-- Preview the generated MDL (output only, no changes)
SQL source GENERATE CONNECTOR INTO HRModule;

Generate for Specific Tables

-- Generate for selected tables only
SQL source GENERATE CONNECTOR INTO HRModule TABLES (employees, departments);

Generate and Execute

-- Generate and immediately apply to the project
SQL source GENERATE CONNECTOR INTO HRModule
  TABLES (employees, departments)
  EXEC;

The EXEC flag executes the generated MDL against the connected project, creating the entities and microflows in one step.

What Gets Generated

The connector generation inspects the external database schema and produces:

  • Non-persistent entities for each table/view, with attributes matching column types
  • Microflow activities using EXECUTE DATABASE QUERY for querying the external database
  • SQL type mapping from database column types to Mendix attribute types

Type Mapping

External database types are mapped to Mendix types:

SQL TypeMendix Type
VARCHAR, TEXT, CHARString
INTEGER, INT, SMALLINTInteger
BIGINTLong
NUMERIC, DECIMAL, FLOAT, DOUBLEDecimal
BOOLEAN, BITBoolean
DATE, TIMESTAMP, DATETIMEDateTime
BYTEA, BLOBBinary

Workflow

-- 1. Connect to the external database
SQL CONNECT postgres 'postgres://user:pass@host:5432/db' AS source;

-- 2. Explore the schema
SQL source SHOW TABLES;
SQL source DESCRIBE employees;

-- 3. Preview the generated MDL
SQL source GENERATE CONNECTOR INTO HRModule TABLES (employees, departments);

-- 4. Review the output, then execute
SQL source GENERATE CONNECTOR INTO HRModule TABLES (employees, departments) EXEC;

-- 5. Disconnect
SQL DISCONNECT source;

mxcli run –local

mxcli run --local runs a Mendix app in a warm, Docker-free dev loop. It keeps a mxbuild --serve process and a standalone Mendix runtime hot, so after the first (cold) build a model change rebuilds incrementally and is hot-applied — without a full rebuild-image-and-restart cycle.

mxcli run --local -p app.mpr
mxcli run --local -p app.mpr --watch
mxcli run --local -p app.mpr --watch --detach   # in the background

Running it as a background service

A coding agent’s tool call has to return, so the loop can run detached and be managed in one call per question — no nohup, no sleep/grep/curl polling loops, no pkill:

$ mxcli run --local --watch --detach -p app.mpr
running: http://127.0.0.1:8080/ (pid 30133, watch, log .mxcli/run.log)

$ mxcli exec add-page.mdl -p app.mpr && mxcli run wait -p app.mpr
applied: build #2 via reload in 4.9s

$ mxcli run status -p app.mpr
running: http://127.0.0.1:8080/ (pid 30133, up 22s, watch, build #2 applied via reload 0s ago)

$ mxcli run stop -p app.mpr
stopped: pid 30133 in 300ms
CommandDoesExit
run --local … --detachstarts the loop in its own session (it survives the calling shell), writes .mxcli/run-state.json and .mxcli/run.log, returns once the app serves. A failed boot prints the reason and the last log lines. A second run for the same project is refused.0 / 1
run statusone line: URL, pid, uptime, last build and how it was applied, a build in progress, a change still pending. A state file whose process is gone is reported as stopped, with any leftover processes. A run whose runtime stopped on its own says why when its log does — stopped: trial licence time limit reached at 18:43:16 (runtime.log); restart with mxcli run restart -p ….0 running, 3 stopped, 4 starting
run wait [--timeout 5m]blocks until the watch loop has applied the model as it is now, or failed to; prints applied: build #N via reload|restart in … or failed: with the CE codes / SCSS error lines. --ready waits for the boot instead; --since N for any build newer than N.0 applied, 1 failed, 2 timeout, 3 not running
run stopasks the run to shut down, waits until it is gone, then stops anything left in its process session — so no orphaned runtimelauncher or mxbuild JVM, even from a run killed with -9. Nothing running is a no-op.0
run restartrun stop, then --detach again with the arguments recorded in the state file.as --detach
run clear-sessionsDeletes every session in the run’s database, so the unlicensed runtime’s 6 sign-in slots are free again without a restart (a 7th sign-in otherwise fails with HTTP 560, shown as “Sign in failed”). Signed-in browsers sign in again. Uses the run’s recorded --db-* flags; pass --db-password when the run was given one literally. Not for --db-type hsqldb: restart instead.0 cleared, 1 failed, 3 no run recorded

run wait is race-free. It reads the model’s source time when it starts and returns the first build made from that source or newer — so a change the loop applied before wait was called is reported at once rather than waited for, and mxcli exec x.mdl && mxcli run wait is a correct one-call chain. With nothing changed it reports the last build. On Mendix versions whose serve process only builds completely once (below), the loop restarts that process per change; if even the fresh one fails, wait reports the failure with its explanation — it never hangs on it. A run started without --watch never applies a change, and wait says so (exit 1).

run status|wait|stop also work on a foreground run --local started in another terminal — every run publishes its state. On Windows, run stop ends the process tree without a graceful shutdown.

.mxcli/run-local.json

Besides run-state.json, a run publishes .mxcli/run-local.json (mode 0600): the app and admin ports, the admin password and the boot configuration — how mxcli oql, mxcli log, constant set --apply, and any tool that calls the admin API, reach the app.

  • Written once the app serves, carrying the run’s pid.
  • Removed when that run ends — a requested stop, or the runtime stopping on its own (the trial licence’s time limit, a crash) — immediately after run-state.json records "phase": "stopped", "ended" and a one-line "reason". run stop removes it for a run that was killed outright. No other process removes it: a run --local --setup (the SessionStart hook) or a refused second run leaves it alone.
  • Missing? Check mxcli run status -p app.mpr, not the credentials. It reports a stopped run with its reason, or a live run whose handshake is gone (mxcli run restart republishes it). A 401/403 from the admin API without the handshake means the connection fell back to default settings; oql and log say so.

Why

The Docker path (mxcli docker run) rebuilds a full deployment package and restarts the container on every change (~30–60 s). run --local instead:

  • keeps the model loaded in mxbuild --serve — a warm rebuild is ~1 s;
  • keeps the runtime process up and applies each change over the M2EE admin API;
  • chooses the cheapest apply automatically from the build’s restartRequired flag:
ChangeApplyCost
page / microflow / nanoflow / texthot reload_model (no restart)~1 s
entity / view entity / associationruntime restart + DDL~9 s

The metamodel catalog (entities/associations) is reconciled only at runtime startup, so structural changes need a restart; behavioural changes do not. A restart drops every browser session — --watch prints browser sessions were dropped; log in again when it happens, so a browser test knows to sign in before its next step.

What it does

  1. Detects the project’s Mendix version.
  2. Ensures MxBuild and the runtime are cached (downloads once, reused after).
  3. Checks the database is reachable (it does not provision it — see below).
  4. Starts mxbuild --serve and does the first (cold) build into deployment/.
  5. Bundles the browser client (web/dist/) with mxbuild’s rollup tooling — the serve Deploy target writes client source but not the bundle, so this step is what makes pages render.
  6. Boots a standalone runtime against that deployment and serves it.
  7. With --watch, rebuilds, re-bundles the client, and hot-applies on every project change until Ctrl-C.

Requirements

  • Mendix 11.x project. The runtime is launched under JDK 21; version-aware JDK selection for Mendix 9/10 is a follow-up.

  • A PostgreSQL database. Defaults: 127.0.0.1:5432, user mendix, database derived from the project file name (App1112.mpr → app1112). Two ways to have it:

    • --ensure-db (recommended for a fresh session) provisions it: starts the local Postgres server if the port is down, and creates the app role + database if missing. It uses a service manager, or a user-owned initdb/pg_ctl cluster under ~/.mxcli/postgres when no service becomes ready (e.g. Arch) — no postgres OS account or sudo required. For a non-local --db-host it only verifies reachability — mxcli won’t provision a remote database. The user-owned cluster persists across sessions; its server log is ~/.mxcli/postgres/server.log. Stop it with pg_ctl -D "$HOME/.mxcli/postgres/data" stop. To remove it, stop it first and then delete ~/.mxcli/postgres (this permanently deletes its databases). The fallback never replaces a stopped system cluster for the same port (/var/lib/postgresql/<ver>/<name>, /var/lib/pgsql/data, /var/lib/postgres/data, Homebrew’s): a new cluster there would hold none of the app’s data while looking healthy, so --ensure-db stops, names the data directory and prints the command that starts it (e.g. sudo pg_ctlcluster 16 main start). If that cluster is not this app’s and cannot be started, MXCLI_ENSURE_DB_NEW_CLUSTER=1 creates the user-owned cluster anyway. Whenever a new cluster is initialized, the output says WARNING: created a NEW, EMPTY PostgreSQL cluster; an existing user-owned cluster is restarted without it. A server whose postgres superuser has a password — every Windows install, where the installer always sets one — needs that login: --ensure-db --db-admin-user postgres --db-admin-password <password>, or set PGPASSWORD (or a .pgpass entry) for postgres, which keeps the password off the command line. The admin login only creates the role and database; the app still connects as --db-user.

    • Otherwise create it once yourself; without --ensure-db, run --local stops with an actionable message if the DB is unreachable:

      createdb -h 127.0.0.1 -U mendix app1112
      
  • Or no database server at all: --db-type hsqldb runs on the runtime’s built-in file database. Use it where PostgreSQL is not installed or cannot be provisioned — e.g. --ensure-db failing with no local PostgreSQL superuser available to create the role/database. See the --db-type row below.

At start, run --local warns (without stopping) when the project’s git state would make Studio Pro 11.13 fail to open it — a branch with no upstream, or “dubious ownership”. See Working Outside Studio Pro.

Flags

FlagDefaultPurpose
--local—Required; run without Docker (implied by --hub)
--hub—Expose the running app in a browser at a tunnel-hub URL (see External browser preview)
--hub-secret—Shared auth (user:pass) matching an open hub’s --secret
(hub API key)—For a GitHub-authenticated hub: get one from https://<hub>/cli, set MXCLI_HUB_KEY (see below)
--watchoffRebuild + hot-apply on every project change
--web-client-timeout$MXCLI_WEB_CLIENT_TIMEOUT, else 5mLimit for one web client bundle build (e.g. 10m); on timeout the tail of deployment/log/web-client-build.log is printed
--build-timeout$MXCLI_BUILD_TIMEOUT, else 30mLimit for one mxbuild --serve build. The cold first build of a large app (thousands of documents) can take ten minutes or more
--start-timeout$MXCLI_START_TIMEOUT, else 15mLimit for the runtime start, after-startup microflow and schema update included. Running out says “did not answer”, not “is the app running?”
--ensure-dboffProvision local Postgres + the app database if missing (fresh-session bootstrap)
--db-admin-user / --db-admin-passwordpostgres / —Superuser login --ensure-db creates the role and database with when it has a password (Windows). Only with --ensure-db; never passed to the runtime
--setupoffPrepare prerequisites (cache MxBuild+runtime, ensure DB) and exit without booting — for a SessionStart hook
--app-port8080App HTTP port
--admin-port8090M2EE admin API port
--serve-port6543mxbuild --serve port
--listen-addr127.0.0.1IP address the app binds to (not a host name). 0.0.0.0 binds all IPv4 interfaces, :: all interfaces, so other machines on the network can open the app; the URLs it is reachable at are printed after boot. The runtime does not derive an application root URL from this address, so if the app builds absolute URLs (SSO redirects, links in emails) pass --runtime-setting ApplicationRootUrl=http://<host>:<port>/ or set a real host in the configuration (a warning says so). The M2EE admin API is unaffected and always binds 127.0.0.1
--mxbuild-pathresolved for this hostThe mxbuild to build with, overriding resolution (Studio Pro on macOS/Windows, the cached CDN download on Linux). MXCLI_MXBUILD_PATH sets the same override from the environment; the flag wins
--db-host127.0.0.1:5432Database host:port; bracket IPv6 endpoints ([::1]:5432)
--db-namederived from projectDatabase name
--db-user / --db-passwordmendix / mendixDatabase credentials; the password takes @env:VAR / @file:path — see Secrets off the command line
--db-typepostgresqlpostgresql or hsqldb. hsqldb boots on the runtime’s built-in file database: no database server, no provisioning, works offline. Its data lives in <project>/deployment/data/database/hsqldb/. Cannot be combined with --db-host, --db-user, --db-password or --ensure-db (refused, not ignored). For local development only
--screenshotoffCapture a Playwright PNG after boot and each applied change
--screenshot-path<projectDir>/.mxcli/run-local.pngScreenshot output PNG
--screenshot-urlapp rootPage to shoot: full URL, or a path relative to the app root (e.g. /p/customers). Repeat for a multi-page set.
--screenshot-user / --screenshot-password—Log in once (Mendix form auth) and reuse the session, so pages behind login render authenticated
--runtime-log<projectDir>/.mxcli/runtime.logRuntime log file — JVM stdout/stderr and the application log (server stack traces + microflow LOG output); - disables
--test-endpointoffHost mxcli’s token-guarded test endpoint so mxcli test … --attach runs a suite against this app with no boot of its own. Installed before the boot (the handler registers from after-startup, so it cannot be added to a running app); your project’s own after-startup microflow is chained, not displaced. Removed on exit; one left behind by a run that was killed (with its container, say) is removed by the next run, and run and check -p warn while the project’s after-startup microflow is still MxTest.RegisterEndpoint with no run hosting it, so the swap is not committed. Tests then use this app’s database
--debugoffEnable the microflow debugger at boot; then use mxcli debug from another terminal. Behaviour-neutral until a breakpoint is set
--debug-passmxdebugDebugger password when --debug is set
--metricsoffRegister a Prometheus meter registry; metrics served at http://127.0.0.1:<admin-port>/prometheus
--traceoffEnable OpenTelemetry tracing (bundled agent → runtime log) with default span filters
--trace-service.mpr nameOTEL_SERVICE_NAME under --trace
--trace-otlpoff (console)Export traces to this OTLP collector endpoint (e.g. http://127.0.0.1:4318) instead of the console; implies --trace (needed for flame charts)
--runtime-setting Key=Value—Merge an extra runtime setting into the boot config (Value parsed as JSON when possible); repeatable

Secrets off the command line

A value typed on the command line is visible in ps for as long as the app runs and stays in shell history. The secret flags — --db-password, --db-admin-password, --screenshot-password, --hub-secret, --debug-pass — and every --constant value can name where the secret is instead:

export MX_DB_PASSWORD=…        # from your secret store, a .env loader, CI secrets
mxcli run --local --watch --detach -p app.mpr --db-password @env:MX_DB_PASSWORD
mxcli run --local -p app.mpr --db-password @file:$HOME/.config/mxcli/db-password
FormReadsRefused when
@env:VARthe environment variable VARit is unset or empty
@file:paththe file, minus one trailing newlineit is empty, not a regular file, or readable by its group or others (Unix; Windows is not checked)
@@textthe literal @text—

Any other value starting with @ is refused, so a mistyped @evn:VAR is not used as the password. Errors name the flag and the variable or file, never the value.

Only the reference is stored in .mxcli/run-state.json, so a run started this way can be detached and restarted: run restart resolves it again, in the environment of the shell that runs the restart (a relative @file: path is stored as an absolute one). A secret written literally is not stored, and run restart refuses that run and says so. The resolved database password is still in the owner-only .mxcli/run-local.json while the app runs, because re-applying constants needs it.

PGPASSWORD is not read for --db-password: it is what --ensure-db hands psql for the superuser, which is usually a different account. Use --db-password @env:PGPASSWORD if they really are the same.

A password with leading or trailing whitespace may not work whatever the source: the Mendix 11.16 beta runtime was observed to trim DatabasePassword (reported in mendixlabs/mxcli#1761). mxcli passes the value unchanged.

Metrics and OpenTelemetry

--metrics registers a Prometheus meter registry at boot, so http://127.0.0.1:8090/prometheus (the admin port) serves the runtime’s Micrometer metrics (connectionbus_*, handler_requests_total, sessions_*, taskqueue_*, …). For another registry, use --runtime-setting:

mxcli run --local -p app.mpr --metrics
mxcli run --local -p app.mpr --runtime-setting 'Metrics.Registries=[{"type":"otlp"}]'

These are flags rather than a post-boot call because the admin update_configuration action replaces the whole config (no read-back), so a separate call would wipe the DB/BasePath settings — --metrics/--runtime-setting merge into mxcli’s single boot update_configuration.

Traces (--trace): attaches the bundled OpenTelemetry Java agent to the runtime JVM (console exporter → runtime.log) and applies default span filters — unfiltered per-activity tracing is ~10× slower, so --trace ships OpenTelemetry._RuntimeSpanFilters=["CreateOrChangeVariable","Loop","Gateway","RetrieveFromCache"] (override via --runtime-setting).

mxcli run --local -p app.mpr --trace
tail -f .mxcli/runtime.log     # microflow spans: mx.microflow.name / mx.microflow.depth

The console exporter omits start/end timestamps and parent span IDs, so call trees and durations can’t be reconstructed from it — for flame charts, export to an OTLP collector with --trace-otlp <endpoint> (implies --trace; sets OTEL_TRACES_EXPORTER=otlp, OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf, and the endpoint for you):

mxcli run --local -p app.mpr --trace-otlp http://127.0.0.1:4318

You can still set the OTEL env yourself for full control (--trace / --trace-otlp won’t override an exporter you’ve set):

export OTEL_TRACES_EXPORTER=otlp OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
mxcli run --local -p app.mpr --trace

Debugging a server-side error

When a page action throws, the browser shows the generic Mendix error dialog with no detail. The runtime log is written to <projectDir>/.mxcli/runtime.log (the path is printed at boot) — tail -f .mxcli/runtime.log while you reproduce the action to see the server stack trace and your microflow LOG output.

A standalone runtime attaches no log subscriber by default (a Studio Pro / m2ee run does), so mxcli wires two sources into that file: it tees the runtime JVM’s stdout/stderr, and after start it attaches a Mendix file log subscriber so the application log (microflow LOG output and server-side stack traces) lands there too — otherwise the file would be nearly empty. The file is appended across restarts, each marked with === runtime start … ===; the subscriber is re-attached on each restart and never rotates the file. Use --runtime-log <path> to relocate it or --runtime-log - to turn it off.

External browser preview (--hub)

Linux builds only. --hub and mxcli tunnel-hub are available in the Linux build of mxcli only.

The tunnel exists to get a preview out of a Linux container, which is the only place it ever ran. Shipping it in the Windows and macOS binaries meant those binaries embedded a general-purpose tunnelling tool they could never use — and endpoint security noticed: Microsoft Defender flagged the Windows binary, and enterprise EDR (Defender for Endpoint, CrowdStrike, SentinelOne) flags this class of payload harder still. That blocked mxcli on exactly the managed corporate laptops most Mendix developers work on.

So it is built for Linux only. On Windows and macOS the commands still exist and still show help, but fail with a message pointing you here. To use --hub, run mxcli inside the project’s devcontainer (or any Linux container) — which is where the warm loop already runs. Everything else in mxcli run --local is unaffected.

We deliberately did not hide the dependency to dodge the scanners; that would be dishonest and would make the binary less trustworthy, not more. The fix is not shipping the capability where it is not used. See ADR-0009.

--hub <url> makes the running app reachable in a browser at a public URL — without the app leaving this machine and without committing. It’s for reviewing work-in-progress from a phone or tablet, or from an egress-only environment such as Claude Code on the web. The app stays local and a reverse tunnel dials out to a hub over 443; the hub proxies browser requests back down the tunnel. Nothing is pushed — only live HTTP — and because everything rides a single 443 connection, it works even from an egress-only proxy.

You run your own hub — there is no hosted service. Stand up mxcli tunnel-hub once on a host you control (a small VPS with a domain), then point apps at it.

# on your VPS: *.example.com + hub.example.com -> this host, inbound 80+443 open
mxcli tunnel-hub --domain example.com --secret alice:s3cret

# where the app runs:
mxcli run --hub https://hub.example.com --hub-secret alice:s3cret -p app.mpr
#   -> registers and prints e.g. "Preview available at https://app.example.com"

The hub is multi-tenant: it fronts many previews at per-preview subdomains (<project>-<branch>.example.com; main/master collapses to <project>) — across projects, solutions, branches, and worktrees — with a sortable overview at https://hub.example.com/. Each run --hub self-registers:

  • Project and branch auto-detect from the .mpr name and git; override with --hub-project/--hub-branch, and --hub-worktree separates worktrees of one branch.
  • --hub-prefix namespaces the hostname (org/solution/team/env) → <prefix>-<project>-<branch>; --hub-solution groups a solution’s apps in the overview.
  • The overview groups previews by Claude Code session (agent): each session lists the endpoints it exposed, links back to its claude.ai/code conversation, and shows its availability — a reaped/idle container turns stale, then offline. run --hub auto-detects the session from CLAUDE_CODE_REMOTE_SESSION_ID (override with --hub-session / MXCLI_HUB_SESSION). Past sessions are retained so you can see older ones: the hub persists a per-session endpoint history to --sessions-file (default ~/.mxcli/hub-sessions.json, survives restarts) and prunes it after --session-retention (default 30 days). Re-registering keeps a stable URL.
  • Each endpoint shows first seen alongside last-seen, last-used, and uptime. First seen is read from that persisted history, so it is the first time the endpoint was ever exposed — unlike uptime, it does not reset when an idle container is reaped and the preview reconnects.
  • --hub implies --local, boots the runtime with ApplicationRootUrl set to the assigned URL (so the SPA and originURI cookie work), and the tunnel reconnects forever. Combine with --watch for the full remote loop: edit here → hot-apply → refresh the tab.

Hub setup: a wildcard *.example.com A record (and hub.example.com) pointed at the VPS; inbound 80 + 443 open — a Let’s Encrypt cert is issued per subdomain on demand.

Authenticated hub (GitHub)

A hub started without the GitHub flags is open — the shared --secret gates registration, so keep it to people you trust. A hub started with GitHub OAuth adds per-user isolation: viewers sign in with GitHub and see only their own previews, and each preview is owned by whoever registered it.

# create a GitHub OAuth App (callback https://hub.example.com/auth/github/callback), then:
mxcli tunnel-hub --domain example.com --secret alice:s3cret \
  --github-oauth-client-id <id> --github-oauth-client-secret <secret> \
  --session-secret "$(openssl rand -hex 32)" --audit-log ~/.mxcli/hub-audit.jsonl

With auth on, --require-auth (default) 302s an unauthenticated viewer to GitHub and returns a 403 to a non-owner; --require-auth=false filters the admin listing but leaves previews open. Hub API keys are stored durably (--keys-file, default ~/.mxcli/hub-keys.json) so they survive restarts.

Get a key (any device, including Claude Code web/mobile): open https://hub.example.com/cli, sign in with GitHub, click Create a hub key, and set it as an environment/repo secret:

export MXCLI_HUB_KEY=<key>
mxcli run --hub https://hub.example.com -p app.mpr   # registers previews as you

The key is durable and does not expire — set it once. Manage it from the same page (“Revoke all keys”), or headless with mxcli auth hub login --token <github-pat>. If registration fails (unreachable hub, stale key), run --hub warns and continues as a normal local run instead of aborting.

The change signal

--watch watches two source trees and rebuilds when either changes:

  • the model source — the .mpr file and the mprcontents/ document tree (v2); and
  • the theme source — theme/ (app-level main.scss, custom-variables.scss, …) and themesource/<module>/web/ (per-module SCSS/CSS/JS).

It does not watch the whole project dir. This is deliberate: the serve/mxbuild build rewrites deployment/, theme-cache/, and .mendix-cache/ on every run, and screenshots land in .mxcli/; watching only the source keeps that build-output churn from re-triggering the loop. Both signals are mtime polling (default 1 s), so they work on container filesystems where inotify does not fire — no watcher fd is involved.

Each applied change is logged with a build generation counter (build #2, build #3, …; the boot build is #1), so “did my change take?” is answerable from the log instead of guessed.

The intended cycle: an agent (or you) edits the model with mxcli exec/MDL — or edits a theme .scss — and the running run --local picks it up and hot-applies it.

A rebuild starts once the source stops changing, not on the first change. An mxcli exec of a real script rewrites the .mpr and many mprcontents/*.mxunit files over several seconds; building on the first change would deploy whatever was on disk at that instant — a half-applied model. The watcher therefore waits for a couple of quiet polls before it builds, so a long exec produces one build of the finished model rather than a build of the first file it touched.

This matters more than it used to. The old escape hatch was “run the script again”, and byte-idempotent exec closed it: re-running an already-applied script writes nothing, so nothing re-triggers the watcher and the stale build has no way out. If you do need to force a rebuild without changing anything, touch the .mpr — the signal is mtime.

Editing themes (SCSS): rebuild, don’t clear caches

A theme edit (e.g. theme/web/main.scss) needs a rebuild, not a cache-clear. mxbuild --serve recompiles the theme on its next /build, and that recompile correctly picks up SCSS content changes — there is no incremental-theme cache to clear (verified: one /build after an main.scss content edit changes theme-cache/web/theme.compiled.css). So:

  • With --watch — just save the .scss; the theme source is watched and the loop rebuilds and hot-applies automatically. One exception, in mxbuild --serve itself: after a theme error, the same serve process keeps reporting that first Sass error — as [CE6093] The theme could not be compiled, quoting a line no longer in the file — on every later build, even once the SCSS compiles (measured on 11.12.5; the bundled Dart Sass compiles the same source cleanly). So when a theme failure follows another one, the loop restarts mxbuild --serve and builds once more, printing “the theme error may be stale … restarting mxbuild –serve”. A theme error that survives the restart is the source’s own, and is the one reported. The fresh build cannot tell a theme-only change from a model change, so that apply restarts the runtime.
  • Without --watch — nothing watches anything, so a save changes nothing in the running app. Trigger a rebuild: restart run --local, or use --watch.

Do not rm -rf theme-cache/ .mendix-cache/ deployment/ — clearing caches is a red herring. If a theme edit “won’t show up”, the cause is that no rebuild ran (Problem above) or a stale process is still serving (below), never a stale compiled-CSS cache.

“My edit didn’t show up” — it’s usually a stale process, not a cache

run --local refuses to boot if its ports (8080 app, 8090 admin, 6543 serve) are already answering — because a previous run --local (or a stray mxbuild --serve / runtime) left alive would otherwise be silently adopted: the startup readiness probes only check that the port answers, so a fresh run would attach to the old process and keep serving old output. That reads exactly like a stale cache but is a stale process.

If you started run --local in the background and the wrapping shell exited non-zero (e.g. a chained sleep/curl that failed), the run --local process can die while its mxbuild --serve + runtime keep serving on :8080. Launch run --local as the sole command in its own invocation — don’t chain a sleep/status check after it in the same shell — and poll separately.

The refusal names the offending process. On Linux mxcli resolves the port’s listener through /proc and prints its pid and command line, so recovery is one command rather than a pgrep hunt:

port 8080 (app) is already in use.
  A stale process is silently adopted otherwise, so edits appear to do nothing
  (looks like a stale cache — it isn't).
  Held by pid 11893: /root/.mxcli/mxbuild/11.13.0/modeler/mxbuild --serve …
  That is a leftover from an earlier run that did not shut down cleanly
  (a kill -9 or a reaped container skips mxcli's own teardown).
    kill 11893
    # confirm it is gone: curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080   (want 000)
  Or run on different ports with --app-port (and --admin-port/--serve-port).

The two cases need opposite remedies, so they are reported differently. A leftover of a previous run is safe to kill, as above. A foreign listener — anything mxcli did not start — is not, and mxcli says so instead of offering a kill:

  Held by pid 4820: python3 -m http.server 8080
  That is not a process mxcli started, so it is not a leftover run —
  pick another port rather than killing it.

Off Linux, or when the listener belongs to another user (so /proc/<pid>/fd cannot be read), it falls back to a generic hint.

A graceful stop needs none of this: each child — mxbuild’s JVM, the runtime, the rollup bundler — is started in its own process group and the whole group is killed on Ctrl-C/SIGTERM. Seeing this error means the previous run never got to run its teardown: kill -9, a crash, or a reaped container. Avoid pkill -f 'mxcli run' — that pattern also matches the shell you type it into.

Pages render in the browser

run --local bundles the browser client (web/dist/) so the app renders in a real browser — verified by driving the pre-installed Chromium with Playwright (the Mendix homepage renders fully). This makes it usable for visual page-design iteration, not just headless checks.

  • Non---watch: a one-shot rollup bundle after the deploy build (~7 s cold).
  • --watch: a long-lived incremental bundler stays hot (the client-side mirror of mxbuild --serve), so a page/widget edit re-bundles in ~3–4 s. It runs with CHOKIDAR_USEPOLLING because inotify does not fire on container overlay filesystems — without it, change detection takes tens of seconds. The loop re-bundles only when the edit touched client source: a microflow/entity edit skips the bundle and just hot-reloads.

Mendix 11.14+ bundles the client itself, so there is no rollup step to run and no bundler to keep hot — run --local prints a line saying so and skips it. Both paths work; the bundle is mxbuild’s rather than mxcli’s.

Classic-client apps need no bundling at all. With Web UI Settings > OptimizedClient = No, mxbuild puts Mendix’s classic (Dojo) client in deployment/web/ — loaded from mxclientsystem and served as-is — and parks the React client in deployment/react-web/. (With Yes it is the other way round: React in web/, Dojo in dojo-web/.) There is no rollup step and no web/dist, so run --local says so and skips it, under --watch too. mxcli reads this from the deployment’s own web/index.html rather than from the model setting, since the deployment is what gets served and the two disagree right after the setting is changed.

--watch when mxbuild writes the client (11.12.5, 11.14)

Where mxbuild bundles the client itself, the first build in an mxbuild --serve process is the only complete one: it does not leave behind the client source (web/pages/, web/layouts/) or the bundler config its own incremental build needs. Every later build in the same process then goes one of two ways:

  • A change that regenerates a page or layout fails inside mxbuild:

    Could not find a part of the path '…/deployment/web/pages/MyModule.Home_Web.js'
    

    (on 11.14 a change that touches no page fails too, with Cannot find module '…/deployment/web/rollup.config.mjs').

  • Any other change — even no change at all — succeeds with an incomplete bundle (11.12.5): deployment/web/dist is replaced by a monolithic index.js and one chunk, with no pages/ and no widgets.css. The reload is reported as applied and every page fails to load.

Measured against mxbuild 11.12.5 over its own HTTP API, no watch loop involved:

BuildrestartRequireddist/ after it
1, coldtrue13 pages, widgets.css, 5.6 KB index.js
2, same process, model unchangedfalse0 pages, no widgets.css, 683 KB index.js
3, same process, after rm -rf web/distfalsesame as 2
4, same process, after rm -rf web/build fails—
5, fresh processtruecomplete again

Nothing done to the deployment helps (also measured on 11.14: switching the app bundler to Rspack, deleting deployment/, restoring the bundler config), and a fresh process always does — a one-shot mxbuild --target=deploy run twice into the same directory succeeds both times. So under --watch mxcli restarts mxbuild --serve and builds again when a build fails this way or leaves dist/ without its pages, and says so in one line:

mxbuild --serve's incremental build left web/dist without dist/pages (13 page modules) and dist/widgets.css (…); restarting mxbuild --serve for a full build...

A change then costs ~25–30 s instead of ~5 s. It is still hot-applied: the incremental build’s restartRequired is kept, because a fresh process’s first build reports true even for an unchanged model, and taking it would restart the runtime — dropping every browser session — on every change. Measured end to end on 11.12.5; on 11.14 the same restart applies to the same failure shape.

Which versions. The two halves have different fates (ako/mxcli#606). The bundler-config failure (Cannot find module '…/rollup.config.mjs') is fixed in Mendix 11.15.0: a project converted from 11.14.0 to 11.15.0 rebuilt warm in 2.8–3.9 s per change, where 11.14.0 failed every build after the first — so on 11.14 the remedy is to upgrade. The per-document client failure (web/pages/…) was still seen on macOS 11.15.0, where mxcli’s serve restart is what keeps the loop going. The hint mxcli prints offers the upgrade only for the half it fixes. mxcli test --attach rebuilds through the hosting app’s own serve process, so it meets the same second-build failure on the same versions (read off the code, not measured).

A CE0108 only the incremental build reports

An incremental build in a serve process that has already built has been seen to fail with CE0108 “Variable ‘X’ is defined but not in scope at this location” on a model that docker check passes and a full build applies (ako/mxcli#2064, 11.16.0, not deterministic). Under --watch mxcli does not report that as the model’s error straight away: it restarts mxbuild --serve and builds once more, and says which answer stood:

the incremental build reports CE0108, which mxbuild --serve has reported for a model a full build accepts; restarting mxbuild --serve for a full build to check...
the full build passed: the incremental build's errors were mxbuild --serve's own state, not the model's

When the full build fails too, the error is the model’s, and the same errors on later saves are reported without another check until a build passes. Other error codes are never rechecked: each check is a cold build.

Pixel-perfect page loop

Pass --screenshot and each applied change is captured to a PNG (default <projectDir>/.mxcli/run-local.png) using Playwright’s built-in screenshot command (Chromium from PLAYWRIGHT_BROWSERS_PATH — no playwright-cli needed):

mxcli run --local -p app.mpr --watch --screenshot
# edit a page with mxcli exec/MDL -> auto rebuild -> re-bundle -> reload -> new PNG

Deep links. --screenshot-url /p/customers shoots a specific page instead of the app root (a bare path is resolved against the app URL; a full http(s)://… is used as-is).

Multi-page sets. Repeat --screenshot-url to shoot several pages after every change — a visual-regression sheet. Each page gets its own PNG, named from the page (run-local-p-customers.png, run-local-home.png):

mxcli run --local -p app.mpr --watch --screenshot \
  --screenshot-url / --screenshot-url /p/customers --screenshot-url /p/orders

Pages behind login. --screenshot-user/--screenshot-password log in once via the Mendix login form (Playwright drives #usernameInput/#passwordInput/ #loginButton), save the session as a Playwright storage state, and reuse it for every screenshot — so authenticated pages render. Login is best-effort: if no login form appears (anonymous app) it proceeds unauthenticated.

mxcli run --local -p app.mpr --watch --screenshot \
  --screenshot-user demo_admin --screenshot-password '<pw>' \
  --screenshot-url /p/customer_overview

Fresh sessions (Claude Code Web)

Background processes (Postgres, the JVM) are reaped on idle, so a resumed web session needs to bring prerequisites back up. mxcli init emits a SessionStart hook into .claude/settings.json that runs ./mxcli run --local --setup --ensure-db -p <app.mpr> on every session start — the non-blocking --setup mode caches MxBuild+runtime and provisions the database, then exits, leaving the session ready to run --local.

To start from an empty repo on the web or an iPad, use the bootstrap prompt instead of a GitHub template.

See also: PROPOSAL_mxcli_dev_warm_loop, mxcli docker run, Playwright Testing.

Bootstrap prompt (empty repo → running Mendix app)

The primary way to start a Mendix + mxcli project from the web or an iPad — no local CLI, no GitHub template to pick from a (short) mobile list. Open an empty repo in Claude Code Web and paste the prompt below; the agent asks you what the app is, then provisions everything and commits the result so future sessions self-bootstrap.

The prompt itself is deliberately tiny — install mxcli, unpack its skills, hand over to the bootstrap-app skill. Everything with detail in it lives in that skill, which ships inside the binary, so the paste stays phone-sized and the procedure is versioned with mxcli instead of with whatever text someone copied months ago.

Why a prompt instead of a GitHub template repo: the mobile “New repository” template dropdown shows only a small subset of templates, and a template repo needs per-Mendix- version upkeep. A prompt starts from a truly empty repo, runs current mxcli, and can seed the model from a design prototype in the same session — nothing to maintain.

The prompt

This is an empty repo. Provision it as a Mendix app developed with mxcli.

1. Make sure `mxcli` is available. The environment may already have it; if not,
   download the prebuilt binary for your OS/arch and put it at `./mxcli`:

   ```bash
   curl -fsSL -o ./mxcli \
     https://github.com/mendixlabs/mxcli/releases/download/nightly/mxcli-linux-amd64
   chmod +x ./mxcli
   ```

2. Unpack the skills that ship inside it — this needs no project, so it works in an
   empty repo:

   ```bash
   ./mxcli init --sync-skills
   ```

3. Read `.ai-context/skills/bootstrap-app/SKILL.md` and follow it end to end. It begins by
   interviewing me about the app, so ask me those questions and wait for my answers
   before running anything else.

If I gave you a design to work from, use it as the source of truth for the model and
the pages: <paste or link a design here — otherwise ignore this line>.

That is the whole prompt. The procedure it used to spell out — the interview, the provisioning steps, the multi-app deltas, the model proposal — now lives in the bootstrap-app skill, which is embedded in the mxcli binary and unpacked by step 2. Two things follow: the prompt is short enough to paste from a phone, and the procedure is fixed by shipping a new mxcli rather than by asking everyone to re-paste a longer prompt.

What the skill does once it takes over

  1. Interviews you — one app or a solution, app name, what the app is for, what it keeps track of, who logs in, theme, Mendix version, and whether you have requirements to work from. The app name comes first because it becomes the .mpr file name, the Studio Pro app name and the path baked into the SessionStart hook.
  2. Provisions — mxcli new into a subfolder and moves it to the repo root (the root is where .claude/ and ./mxcli must live), mxcli init --tool claude, then run --local --setup --ensure-db to cache MxBuild + runtime and create the database.
  3. Writes the brief — README.md (what is being built, in your words) and FINDINGS.md (anything surprising or broken, appended as work proceeds). These are what an idle-reaped session reads to know what it is working on.
  4. Records the plan — the requirements, grouped into deliverable slices, in docs/brain/plan/. On by default; say so at the interview if you would rather skip it. This exists because a specification in a Word document, a Figma file or a chat window leaves no trace in git — not an issue, not a commit message — so hours of work can end up with nothing recording what it was for. Requirements anchor at what will implement them, so mxcli brain plan reports progress derived from the model: building the thing moves the number, and there is no status column to maintain.
  5. Commits, then boots and verifies — HTTP 200 at http://localhost:8080/, plus an optional run --hub preview URL.
  6. Takes the quality baseline — mxcli lint and the scored mxcli report on the blank app, recorded in FINDINGS.md. This is the only moment those numbers mean “what the template ships with”; afterwards every figure is yours plus the template’s, with nothing to subtract. See mxcli report for how to read the six category scores.
  7. Hands over and asks what next — points you at the project explorer (run /reload-plugins, then /mxcli-project; see Claude Code Companion) and offers a choice: propose the domain model, import requirements from a document first, open the explorer, or something else.
  8. Proposes the model in MDL and waits — module, entities, roles, pages — before building anything. Every script it writes, from the first, starts with mdl 1;, so the project never holds a headerless (mdl 0) script to upgrade. Two of those choices it makes deliberately rather than by default: a process with steps someone has to act on becomes a workflow (state machine, user-task inbox, timers, a definition the business can read) rather than a status attribute and some microflows, and a total or count across records becomes a view entity — OQL the database executes — rather than a microflow that retrieves every row to produce one number. Both are cheap to choose at the proposal and expensive to retrofit, because the pages, security rules and tests bind to whichever was picked. The same two rules are in the project’s generated CLAUDE.md, so later sessions apply them without being asked.

For a solution repo it also covers the parts that bite: per-app ports, a hostname per app so the two apps do not share one cookie jar, the root SessionStart hook that mxcli init will not write for you, and wiring OData in dependency order.

Which mxcli version gets installed

Prebuilt binaries are the working install path. CI publishes them on every vX.Y.Z tag (latest is v0.16.0) and as a rolling nightly pre-release, with assets named mxcli-<os>-<arch>.

  • nightly — recommended while mxcli is fast-moving alpha. New features (the whole warm-loop surface: run --local, --watch, --ensure-db, --setup, screenshots) land in nightly before they reach a tagged release, so the bootstrap flow above needs it. Download .../releases/download/nightly/mxcli-<os>-<arch>, or once mxcli is present, mxcli setup mxcli --tag nightly.
  • vX.Y.Z — pin for reproducibility / stability. The CI marks nightly a pre-release (“use tagged releases for production”). Download .../releases/download/vX.Y.Z/mxcli-<os>-<arch> or mxcli setup mxcli --tag vX.Y.Z. With no --tag, mxcli setup mxcli matches the mxcli already running it (nightly → nightly, vX.Y.Z → that release) — mainly useful for replicating a version onto another OS/arch (e.g. the Linux binary in a Dev Container), not the first install.
  • Environment pre-install (the robust path) installs whatever the Claude Code Web image bakes in — the way to pin a known-good version fleet-wide.
  • go install …@latest does not work yet. The module is public (tags v0.1.0– v0.16.0), but the generated ANTLR parser (mdl/grammar/parser/) is gitignored and not committed, so a go install from the tagged source fails on the missing package. Building from source works only via make build/make release (which run make grammar first). Enabling go install would require committing the generated parser (or generating it during module build) — a maintainer decision.

Which Mendix version to ask for

The skill prefers whatever the session environment already provides, and otherwise the newest version with a published MxBuild.

The environment’s contribution is a cached MxBuild, not a variable — a Claude Code session image may bake one in, and reusing it turns a multi-hundred-MB download into no download at all. The cache directory is the signal:

ls ~/.mxcli/mxbuild/ 2>/dev/null | sort -V | tail -1   # e.g. 11.13.0, or empty

With no cached version, the skill takes the newest on the CDN — everything mxcli does starts with downloading MxBuild, so “supported” means “on the CDN”. At the time of writing that is 11.14.0; treat the number as perishable and run the check rather than quoting it:

V=11.14.0
curl -sI -o /dev/null -w '%{http_code}\n' https://cdn.mendix.com/runtime/mxbuild-$V.tar.gz   # 200
curl -sI -o /dev/null -w '%{http_code}\n' https://cdn.mendix.com/runtime/mendix-$V.tar.gz    # 200 (runtime)

Both have to answer 200 — run --local needs the runtime tarball as well as MxBuild. This is also the check to run before bumping the version named in the skill. In a solution, give every app the same version: they share the ~/.mxcli/mxbuild cache, and a mismatch means a second multi-hundred-MB download and two runtimes to keep straight.

Two rules that make this robust

  • Committing the config is mandatory (the skill’s provisioning step 7). The prompt is a one-time seed. Its output — .mpr + .devcontainer/ + .claude/ with the SessionStart hook and bootstrap-mxcli.sh — must be committed so the steady state is file-driven and deterministic. After that, every new session runs the hook automatically; you never re-paste the prompt. Miss the script and the hook has nothing to run after a reap.
  • A failed hook does not block the session — so it says so. The hook runs the script under || true. When ./mxcli run --local --setup --ensure-db fails (for example “no local PostgreSQL superuser available”), the script prints an ERROR: the app database was not provisioned: … banner to stderr with the retry command and the --db-type hsqldb alternative, and exits with the failure’s status.
  • mxcli delivery is an environment concern, not the prompt’s. The download in step 1 is the fragile part in a gated web session (a GitHub release curl may be blocked), and it is the one thing that cannot move into the skill — the skill is inside the binary. The robust fix is for the Claude Code Web environment image / setup script to pre-install mxcli (and pre-cache MxBuild + runtime); go install via proxy.golang.org is the fallback and needs mxcli published as a public Go module.

After bootstrap — the inner loop

./mxcli run --local -p <AppName>.mpr --watch --screenshot   # warm dev loop + screenshots
./mxcli exec change.mdl -p <AppName>.mpr                     # edit the model; the loop hot-applies

The gates

mxcli init writes this list into the project’s CLAUDE.md, so an agent has it in context in every session without being asked — it is the definition of done, not a menu. The same list is in the bootstrap-app skill, and the three are held together by a test, because a gate that is named in two of the three places is a gate that only runs when someone remembers to ask for it.

They run once per change, not per edit: a change is a coherent unit of work, not a single statement and not a file write. Iterate with exec, then run the gates once over the result. That distinction is held by a test too — without it, “definition of done” reads as the whole list after every edit, which is ~55s and five tool calls each time, and was the dominant cost in a measured agent session.

./mxcli check change.mdl -p <AppName>.mpr --references   # syntax + references (~2s)
./mxcli exec change.mdl -p <AppName>.mpr                 # apply
./mxcli lint -p <AppName>.mpr                            # rules (~3s)
./mxcli report -p <AppName>.mpr                          # scored quality report
./mxcli docker check -p <AppName>.mpr                    # mxbuild, the slow one (~25s)
./mxcli test tests/ -p <AppName>.mpr --local             # microflow tests (~30s cold, ~2s warm)
./mxcli run --local --watch --detach -p <AppName>.mpr    # the app, in the background; then `run wait`

Each buys something the one above it cannot: lint and report score the model that check only proved was well-formed, test proves behaviour that a clean build does not, and the running app is the only thing that proves how a page renders. report’s six category scores are comparable against the baseline taken at bootstrap — a score that fell is a finding.

Writing the first test with the first microflow, rather than later, is what keeps test from being permanently skipped: after the code is written the expected values have stopped being obvious. --local needs no Docker daemon, so it works in a web session.

In a solution, run one loop per app from its own folder, with the second app on the alternate ports, and start the producer first so the consumer’s external entities resolve:

(cd backend  && ./mxcli run --local -p Backend.mpr --watch)
(cd frontend && ./mxcli run --local -p Frontend.mpr --watch \
                  --app-port 8180 --admin-port 8190 --serve-port 6643)

With 127.0.0.1 backend.local frontend.local in /etc/hosts, browse them at http://backend.local:8080/ and http://frontend.local:8180/ so each app gets its own cookie jar.

See mxcli run –local for the warm loop, --watch, --ensure-db, and the screenshot flags.

Debug Microflows — mxcli debug

mxcli debug drives the Mendix runtime’s microflow debugger from the command line: set breakpoints by name, inspect a paused microflow’s variables, and step/continue — against an app started by mxcli run --local. It is the headless counterpart to Studio Pro’s debugger.

Because mxcli owns both halves — the admin password + app URL (from run --local) and the activity model GUIDs (from the .mpr) — it can offer breakpoints by name, so you never handle raw GUIDs.

Quick start

# terminal 1 — app with the debugger enabled and a session ready
mxcli run --local -p app.mpr --debug

# terminal 2 — break by name, then inspect/step/continue (same -p)
mxcli debug activities Sudoku.ACT_Hint -p app.mpr
mxcli debug break Sudoku.ACT_Hint --activity 'Retrieve' -p app.mpr
#   ...trigger the microflow in the browser (the request pauses)...
mxcli debug paused   -p app.mpr
mxcli debug inspect Game -p app.mpr
mxcli debug step over    -p app.mpr
mxcli debug continue     -p app.mpr
mxcli debug disable      -p app.mpr        # always finish here

--debug on run --local enables the debugger and starts a session (cached under <projectDir>/.mxcli/), so you skip a separate mxcli debug enable. With no breakpoints set, nothing pauses — --debug alone is behaviour-neutral.

Two APIs, one command

Under the hood the debugger spans two runtime APIs, which mxcli debug hides:

  • the M2EE admin API toggles the debugger (enable/disable/status);
  • the app’s /debugger/ endpoint runs the session (breakpoints, paused state, stepping).

Commands

CommandWhat it does
mxcli debug statusDebugger on? How many microflows paused?
mxcli debug enable / disableTurn on/off (prefer run --local --debug for the warm loop)
mxcli debug activities <Module.Flow>List activities + the object IDs you can break on
mxcli debug break <Module.Flow> --activity <#n|caption> [--if <expr>]Set a breakpoint by name; --if is a conditional (Mendix expression)
mxcli debug unbreak <Module.Flow> --activity <#n|caption>Clear a breakpoint
mxcli debug breaksList the breakpoints set this session (name → object ID)
mxcli debug pausedPaused microflows + full state (variables)
mxcli debug inspect <var> [--list] [--flow <debug_id>]Inspect one variable of a paused flow — an object’s members, or a primitive’s value (String, enumeration, …); --list inspects a list variable (get_list)
mxcli debug step [over|into|out] [--flow <debug_id>]Advance one step (default over)
mxcli debug continue [--flow <debug_id>] [--all]Resume the paused flow (or all with --all)

Selecting an activity: --activity '#2' (the index from activities) or a caption substring like --activity 'Retrieve' (case-insensitive; must match one).

Selecting a paused flow: --flow <debug_id> (from paused); a single paused flow is auto-selected.

Connection flags

Defaults target a run --local runtime; override to debug a differently-configured or remote runtime.

FlagEnvDefault
--app-urlMXCLI_APP_URLhttp://127.0.0.1:8080
--admin-port—8090
--admin-passMXCLI_ADMIN_PASSmxcli-local-dev
--debug-passMXCLI_DEBUG_PASSmxdebug
-p, --project—(for the .mxcli/ session + breakpoint files)

Nanoflows (client-side)

mxcli debug works for nanoflows as well as microflows. break / activities / unbreak auto-detect the document type and set the breakpoint correctly — a nanoflow uses the runtime’s nanoflow_name parameter (the wrong key NPEs the runtime; mxcli handles it). Break by name the same way:

mxcli debug break Sudoku.NF_ToggleNotes --activity 'Change' -p app.mpr

A paused nanoflow does not appear in get_paused_microflows; it surfaces only in the runtime’s poll_events. mxcli debug paused (and step/inspect/continue) merge both sources, so a paused nanoflow appears with its debug_id; its variables are shown under the Client events (poll_events) section of paused.

A nanoflow’s debug_id is single-use — it changes after every step (a microflow’s is stable). Let step/inspect/continue auto-resolve the flow (omit --flow); each command re-reads the current state and picks up the fresh id. A --flow value copied from an earlier paused goes stale after the first nanoflow step.

Nanoflow logging: a nanoflow logs to the browser console and to the server runtime log, but the runtime rewrites the log node to Client_Nanoflow — so in .mxcli/runtime.log a nanoflow’s LOG NODE 'Sudoku' … appears as Client_Nanoflow:, not Sudoku:. Grep for Client_Nanoflow (or the message text); a node-name filter built for microflows drops nanoflow lines. Note LOG DEBUG from a nanoflow is dropped server-side (browser console only) — only INFO/WARNING/ERROR reach runtime.log.

Important behaviour

  • A breakpoint pauses whoever hits it — the browser included. The triggering request hangs until continue or disable. Don’t leave a paused session idle.
  • Always mxcli debug disable when done. run --local --debug does this on shutdown; a manual enable is your responsibility.
  • Use the same -p for every command. The session token and breakpoint record live under <projectDir>/.mxcli/.
  • A request the runtime refuses is an error, with the runtime’s reason — for example the runtime refused add_breakpoint (status 2): Failed to parse condition: … for a bad --if. An activity or flow the running app does not have yet (rebuild or restart after changing the model) and Invalid session token (the runtime restarted, or another debugger such as Studio Pro started a session — run mxcli debug enable) are the other common ones. A command that printed success was accepted by the runtime.

Measuring Agent Sessions

An agent session’s cost is roughly model calls × conversation size: every model call re-reads the whole conversation, and every tool result stays in it. So a call removed takes its result with it, and the bill falls with the square of the call count. Two commands measure where a session’s calls went:

CommandReadsSees
mxcli diag loop-reportmxcli’s own logs (~/.mxcli/logs)mxcli processes: verbs, wall time, exits
mxcli diag session-reportthe agent’s transcriptevery tool call, its result size, failures and retries, tokens

loop-report cannot see the agent’s Read/Edit/Grep/Skill calls, how large a result was, or why a call happened. session-report can, because the transcript records all of it.

mxcli diag session-report

mxcli diag session-report ~/.claude/projects/<project>/<session>.jsonl
mxcli diag session-report --top 20 a.jsonl b.jsonl     # one report each, plus a combined one
mxcli diag session-report --json run/transcript.jsonl

Claude Code keeps transcripts in ~/.claude/projects/<project>/<session>.jsonl, the project being the working directory with every non-alphanumeric character replaced by -. A subagent’s transcript is in <session>/subagents/; those are included unless --subagents=false, and each is costed as its own conversation (its results are re-read by its own calls, not the main session’s).

Example (a real session, abbreviated):

session ea54453f (+9 subagents)  2026-09-25 00:22  wall 31h06m (active 5h16m)
model calls 1269 (main 470)  tool calls 1410  avg context 209k
tokens: cache-read 261.0M  cache-write 4.6M  output 790k  input 2.6k
tool results: 480k tokens, re-read cost 58.7M (22% of cache-read)
tools: Bash 1222, Write 92, Edit 59, Read 12, Agent 9, …
bash 1222: other 648, build 244, mxcli 213, git 117
  mxcli 371 invocations (90 calls chained >1): exec 89, check 75, docker check 61, …
category: orientation 668 (47%), write 248 (18%), validate 123 (9%), apply 51 (4%), verify 138 (10%), diagnosis 11 (1%), retry 103 (7%), delegate 9 (1%), other 59 (4%)
costliest results (tokens × later calls):
   1   2.2k ×443  =  955k  Bash  sed -n 1139,1200p ….go
   …
failures 124  retry chains 66 (103 retry calls, 57 resolved)
    6 ok   ./bin/mxcli docker check -p …/probe/PedApp.mpr 2>&1 | [error] [CE6083] "Design property …
errors by retries (retries/chains/failures):
    6/1/6 [error] [CEN] "Design property Spacing bottomx is not supported by your theme. …
lookups: 166 file reads, 31 repeats (…/skills/fix-issue.md ×5, …); 12 doc lookups, repeated: skill fix-issue ×5, …

What each line means

  • model calls — distinct API responses. Claude Code writes one record per content block, each repeating the message id, so records are not calls.
  • avg context — input + cache read + cache write per model call.
  • wall / active — first to last record; active leaves out pauses over 15 minutes, so a resumed session is not counted as one long one.
  • tool results / re-read cost — the estimated size of every result (characters ÷ 4; an image width × height ÷ 750, at most 1,600), and the sum of size × the number of later model calls that re-read it, up to the next compaction. The percentage is the share of the cache-read bill that tool results explain; the rest is prompts, system context and the model’s own turns.
  • bash — each Bash call once, under its most significant program: mxcli, playwright, build (make/go/npm/mx…), git, other. The mxcli line counts invocations: exec a.mdl && docker check is one call, two invocations. Verbs are named by mxcli’s own command tree, as in loop-report; a one-shot is named by its statement, -c describe, -c create.

Categories

Every call gets one category. The rules, first match wins:

CategoryRule
retrythe call is in an error→retry chain (below)
writeEdit / Write; a Bash redirect into a file, tee, sed -i, cp, mv; mxcli fmt
applymxcli exec; mxcli -c with create/alter/drop/grant/revoke/move/rename/…
validatemxcli check, lint, docker check, docker build, report, diff; make / go build
verifymxcli test, run, docker run, playwright …, screenshot, oql; playwright and browser tools; curl; go test
diagnosisreading or filtering a log (.log, /logs/, .output, docker logs, journalctl); mxcli diag
orientationRead, Grep, Glob, Skill, ToolSearch, WebFetch; mxcli syntax, help, describe, show, list, -c describe/show/list/select; cat, grep, ls, find
delegateAgent / Task (handing work to a subagent)
othergit, setup (new, init, setup), anything unmatched

A Bash call that chains several commands takes the highest of its parts in the order apply > verify > validate > write > diagnosis > orientation > other, so mxcli exec x.mdl && mxcli docker check is an apply and tail app.log | grep ERROR a diagnosis. cd, echo, export, sleep carry no category.

Failures and retry chains

A result is a failure when Claude Code marked it is_error (for Bash, a non-zero exit), or — for Bash, where ; echo or | tail can mask the exit code — when a line starts like an mxcli or test-runner error: Error:, Parse error:, Reference error:, - line N:N, [error], FAIL, panic:. A grep hit (file.go:12: Error:) does not start the line and does not count.

A failure opens a chain on what the call worked on: the .mdl scripts an mxcli command was given, the file a Read/Edit/Write touched, a file argument of another command, or else the command itself. A later call on the same target joins the chain and is counted as retry; a joining call that runs something and succeeds resolves it. A chain nobody returns to for 8 calls is closed. Chains are aggregated by their first error, with numbers, ids and paths folded (line 3:5 → line N:N), so the families show which errors cost the most iterations.

Lookups

Repeated reads of the same file (Read, cat, head), and repeated documentation lookups: mxcli syntax …, mxcli help …, a Skill call, a read of a SKILL.md or skill file, CLAUDE.md / AGENTS.md.

Sharing a report

The report prints counts and short command, path and error snippets — never prompt, thinking or tool-result text. Absolute paths are cut to their last two elements and long token-like strings are dropped, so the output can be shared without sharing the conversation. --json carries the same fields.

Running the benchmark

mxcli eval run runs Claude Code headless on an eval test and reports the session:

mxcli eval run docs/14-eval/eval-bench-001.md --version 11.15.0 -o ~/bench

It creates a fresh project with mxcli new (which runs mxcli init, so the agent gets the CLAUDE.md and skills a user gets, and copies this mxcli into it), runs claude -p <prompt> --dangerously-skip-permissions in it with a known session id, copies the transcript into the run directory, writes session-report.txt / .json, and runs the test’s checks. BENCH-001 is the fixed brief: an order desk with four entities, three associations, overview and edit pages, a validated microflow, two module roles, navigation and a microflow test. Keep it unchanged, so runs before and after a change compare.

The agent needs its own credentials (a logged-in claude, or ANTHROPIC_API_KEY). Variables a hosting Claude Code session sets for its children (CLAUDECODE, CLAUDE_CODE_*) are removed, so a run started from inside an agent session behaves like one started from a terminal.

To report on a run afterwards, or on several:

mxcli diag session-report ~/bench/BENCH-001-*/transcript.jsonl

Docker Integration

mxcli provides Docker integration for building, running, and validating Mendix applications without a local Mendix installation. Docker is also required for the testing framework.

Features

CommandDescription
mxcli docker buildBuild a Mendix application with mxbuild in Docker, including PAD patching
mxcli docker runRun a Mendix application in a Docker container
mxcli docker checkValidate a project with mx check, run locally (does not download mxbuild or use Docker; see Getting mx)
mxcli testRun test files (uses Docker for mx create-project and mx check)
mxcli oqlQuery a running Mendix runtime via M2EE admin API

Prerequisites

  • Docker (or Podman 4.7+) must be installed and running
  • The container runtime must be accessible from the command line

Podman Support

mxcli auto-detects Docker or Podman on PATH. To explicitly select:

export MXCLI_CONTAINER_CLI=podman

Podman 4.7+ ships podman compose with full Docker Compose V2 compatibility. All mxcli docker subcommands work with both runtimes.

The mx Tool

The mx command-line tool validates and builds Mendix projects. mxcli can auto-download the correct version:

# Auto-download mxbuild for the project's Mendix version
mxcli setup mxbuild -p app.mpr

The mx binary location depends on the environment:

EnvironmentPath
Dev container~/.mxcli/mxbuild/{version}/modeler/mx
Repositoryreference/mxbuild/modeler/mx

Quick Start

# Once per Mendix version: download MxBuild (docker check does not download it)
mxcli setup mxbuild -p app.mpr

# Validate a project
mxcli docker check -p app.mpr

# Build a deployable package
mxcli docker build -p app.mpr

mxcli docker build

The mxcli docker build command builds a Mendix application using mxbuild in Docker, including support for PAD (Platform-Agnostic Deployment) patching.

Usage

mxcli docker build -p app.mpr

What It Does

  1. Downloads mxbuild – Automatically downloads the correct mxbuild version for your project if not already available
  2. Runs mxbuild – Executes the Mendix build toolchain in a Docker container
  3. PAD patching – Applies Platform-Agnostic Deployment patches (Phase 1) to the build output
  4. Produces artifact – Generates a deployable Mendix deployment package (MDA file)

The Project Is Not Modified

mx check and MxBuild write into the project they are given: mx check compiles the theme into theme-cache/ and deployment/sass/, and MxBuild regenerates the javasource/ proxies, the .launch, .classpath and .project files and all of deployment/.

So docker build copies the project to a temporary directory (in $TMPDIR) and runs them there. The only thing written is the output directory (.docker/build/ by default, or -o). Build output, caches and VCS folders (deployment/, .docker/, .git/, node_modules/, …) are not copied.

The Project Is Built as Stored

The pre-build check runs on the project as stored, exactly as mxcli docker check checks it, and MxBuild builds that same model: a deployment is built from what is committed. A project whose stored widget definitions are stale (CE0463) therefore fails the build, with each CE0463 labelled as one mxcli fix widgets clears or one that persists — the same verdict docker check, mxcli run --local and Studio Pro give.

FlagChecks and buildsUse
(default)the project as storeda build that matches what is committed
--update-widgetsa copy normalised by mx update-widgets firstbuild anyway; the project keeps its stale widgets, mxcli fix widgets fixes them
--no-update-widgetsthe project as stored, without the re-check that labels CE0463sscripts that passed it before
--skip-checkno check at all—

--update-widgets is how docker build (and docker run / docker reload, which build through it) behaved before ako/mxcli#1794; docker run and docker reload take the same flag.

PAD Patching

PAD (Platform-Agnostic Deployment) patching modifies the build output to be compatible with container-based deployment platforms. This is essential for deploying Mendix applications to Kubernetes, Cloud Foundry, or other container orchestrators.

The build command applies six patches automatically after MxBuild produces the PAD output:

#PatchApplies ToDescription
1Set bin/start execute permissionAll versionsZIP extraction does not preserve the +x flag. This patch runs chmod 0755 on bin/start so the container can execute the start script.
2Fix Dockerfile CMD (start.sh -> start)11.6.x onlyMxBuild 11.6.x generates CMD ["./bin/start.sh"] but the actual script is named start (no .sh extension).
3Remove config argumentAll versionsRemoves the "etc/Default" argument from the CMD line. The bin/start script accumulates args with CONFIG_FILES="$CONFIG_FILES $1", which produces a leading space (" etc/Default") that the runtime rejects. Without the argument, bin/start uses its own DEFAULT_CONF variable.
4Replace deprecated base imageAll versionsReplaces FROM openjdk:21 with FROM eclipse-temurin:21-jre. The openjdk Docker images are deprecated and no longer maintained.
5Add HEALTHCHECKAll versionsInserts a HEALTHCHECK instruction before the CMD line: `curl -f http://localhost:8080/
6Bind admin API to all interfacesAll versionsAppends admin.addresses = ["*"] to the PAD config file (etc/Default). Without this, the admin API (port 8090) only listens on localhost inside the container and is unreachable from outside even when the port is mapped.

Patches are applied idempotently – if a patch has already been applied (e.g., from a previous build), it is skipped. The build output shows the status of each patch:

Applying patches...
  [applied] Set bin/start execute permission
  [skipped] Fix Dockerfile CMD (start.sh -> start)
  [applied] Remove config arg from Dockerfile CMD
  [applied] Replace deprecated openjdk base image
  [applied] Add HEALTHCHECK instruction
  [applied] Bind admin API to all interfaces

Validation with mx check

To validate a project without building:

mxcli docker check -p app.mpr

This runs mx check against the project and reports any errors:

Checking app for errors...
The app contains: 0 errors.

Auto-Download mxbuild

mxcli automatically downloads the correct mxbuild version for your project:

# Explicit setup
mxcli setup mxbuild -p app.mpr

# Or let docker build handle it automatically
mxcli docker build -p app.mpr

The downloaded mxbuild is cached at ~/.mxcli/mxbuild/{version}/ for reuse.

mxcli docker check

The mxcli docker check command validates a Mendix project without building it. It runs the Mendix mx check tool against the project and reports errors, warnings, and deprecations.

Usage

mxcli docker check -p app.mpr

What It Does

  1. Resolves the mx binary – Takes the mx next to --mxbuild-path, else the first it finds on PATH, in a Studio Pro installation, or in the download cache at ~/.mxcli/mxbuild/<version>/modeler/mx. It does not download one (see Getting mx)
  2. Runs mx check – Executes the Mendix project checker against a temporary copy of the project as stored, as a local process; despite the docker in its name, it does not use Docker
  3. Reports results – Outputs errors, warnings, and deprecations to the console
  4. Explains CE0463 – Only when the check found a CE0463, re-checks the copy after mx update-widgets and labels each one (see CE0463 and stale widget definitions)

If the stored project has errors, the command exits with a non-zero status code.

Before mx check it also warns, without failing, about two problems mx check cannot see: an MPR v2 ContentsHash index that disagrees with the .mxunit files (mxcli fix hashes), and git states that make Studio Pro 11.13 fail to open the project. See Working Outside Studio Pro.

Getting mx

docker check does not download MxBuild. With no mx to find it stops with mx not found and names the command that sets one up:

# Once per Mendix version: download MxBuild (including mx) for the project's version
mxcli setup mxbuild -p app.mpr

# Then check
mxcli docker check -p app.mpr

The downloaded mxbuild is cached at ~/.mxcli/mxbuild/{version}/ for reuse. mxcli new and mxcli docker build download it too. On macOS and Windows, setup mxbuild prefers Studio Pro’s bundled MxBuild, because the CDN only publishes Linux binaries.

The mx found need not be the project’s version: an mx on PATH, or any cached version, is used when the project’s own is not there. mx check accepts another patch of the same minor; mx update-widgets does not and the check then runs without it (see mxcli docker check --help). Pass --mxbuild-path ~/.mxcli/mxbuild/<version> to choose one.

CE0463 and stale widget definitions

CE0463 (“The definition of this widget has changed”) means a stored pluggable widget instance does not match its widget package. Its commonest cause is harmless to fix: the package was upgraded after the widget was placed (any Marketplace or widget install does this), and Studio Pro’s “Update all widgets” resyncs it. But until it is resynced, MxBuild and mxcli run --local refuse the project, so docker check reports it.

When the check of the stored project finds a CE0463, docker check runs mx update-widgets on the same temporary copy, checks it again, and labels each CE0463 of the stored project:

LabelMeaningFix
stale widget definition — `mxcli fix widgets` fixes itthe resync clears itmxcli fix widgets -p app.mpr updates the project’s widgets and keeps MPR v2
persists after update-widgetsthe resync does not clear it: something in the stored widget is wrong in a way a resync does not repair (if mxcli wrote the widget, that is an mxcli defect worth reporting)not a resync; see Pluggable Widgets

The verdict and the exit status are always the stored project’s. The re-check costs a second mx check, so it runs only when there is a CE0463 to explain.

mx check does not print the page a CE0463 is on, so widgets of the same name on different pages print the same line; the labels count them ((4×), or (2 of 3) when a resync clears only some of them).

FlagChecksExit statusLabels
(none)the project as storedthe stored project’syes, when there is a CE0463
--no-update-widgetsthe project as storedthe stored project’sno: update-widgets never runs
--update-widgetsonly a copy normalised by mx update-widgets firstthe normalised copy’sno

--update-widgets is how docker check behaved by default in earlier releases. It reports 0 errors for a project whose only errors are stale widget definitions, which then still fails to build: use it only when you mean to ask “would this pass after mxcli fix widgets?”. --no-update-widgets is kept for scripts that pass it; its verdict is the default’s.

If mx update-widgets fails during the re-check (it refuses a project whose Mendix version is not exactly its own, exit status 4), the CE0463s are left unlabelled and the output says so.

Example Output

A clean project:

Using mx: /home/vscode/.mxcli/mxbuild/11.13.0/modeler/mx
Checking a temporary copy (mx writes into the project it checks; the project on disk is not changed).
Checking project /workspaces/app/app.mpr...
Checking app for errors...
The app contains: 0 errors.
Project check passed.

A project whose Data grid 2 package was upgraded after its grids were placed (measured on Mendix 11.13.0; the long CE0463 message is shortened here):

Checking project /workspaces/app/app.mpr...
Checking app for errors...
[error] [CE0463] "The definition of this widget has changed. ..." at Data grid 2 'dataGrid2_1'
[error] [CE0463] "The definition of this widget has changed. ..." at Data grid 2 'dataGrid21'
...
The app contains: 11 errors.

The stored project has 11 CE0463 errors. Re-checking the temporary copy after
  `mx update-widgets` to tell stale widget definitions from other causes...
CE0463 in the stored project:
  at Data grid 2 'dataGrid2_1' (4×): stale widget definition — `mxcli fix widgets` fixes it
  at Data grid 2 'dataGrid21' (4×): stale widget definition — `mxcli fix widgets` fixes it
  at Data grid 2 'dataGrid2_2' (2×): stale widget definition — `mxcli fix widgets` fixes it
  at Data grid 2 'dataGrid2_3': stale widget definition — `mxcli fix widgets` fixes it
All 11 are stale widget definitions. This updates them in the project, as Studio
  Pro's "Update all widgets" does:
    mxcli fix widgets -p /workspaces/app/app.mpr
As stored: 11 errors. After update-widgets: 0 errors.
Error: project check failed: exit status 1

When to Use

mxcli docker check is faster than a full build (mxcli docker build) because it only validates the project structure without compiling or packaging. This makes it useful for:

  • Quick validation after making MDL changes
  • Pre-build gate – mxcli docker build runs the same check automatically before building, on the project as stored (use --skip-check to bypass; --update-widgets checks and builds a widget-normalised copy instead)
  • CI pipelines – fail fast on invalid projects before spending time on a full build
  • Iterative development – check after each MDL script execution to catch errors early

Integration with mxcli docker build

The mxcli docker build command runs mx check automatically as a pre-build step. If the check fails, the build is aborted. You can skip this with:

mxcli docker build -p app.mpr --skip-check

Integration with the TUI

When using mxcli in interactive REPL mode, the TUI can auto-check the project on file changes, giving immediate feedback on whether MDL modifications introduced errors. Like docker check, it runs mx check on a temporary copy, so the project’s theme-cache/ and deployment/ are not touched; it does not run update-widgets, so it checks the project as stored. mxcli eval’s mx_check check does the same.

mxcli docker run

The mxcli docker run command runs a Mendix application in a Docker container, providing a local runtime environment for testing and development.

Usage

mxcli docker run -p app.mpr

What It Does

  1. Builds the application if no build artifact exists
  2. Starts a Docker container with the Mendix runtime
  3. Configures the runtime with the project’s settings (database, ports, etc.)
  4. Exposes the application on the configured HTTP port

Prerequisites

  • Docker (or Podman 4.7+) must be installed and running
  • The project must be buildable (no errors in mxcli docker check)
  • A PostgreSQL database must be available (Docker can provide one)

Runtime Configuration

The runtime uses configuration from the project’s settings. You can view and modify these with:

mdl 1;
LIST SETTINGS;
DESCRIBE SETTINGS;
ALTER SETTINGS CONFIGURATION 'default' ( DatabaseType: 'POSTGRESQL' );
ALTER SETTINGS CONFIGURATION 'default' ( HttpPortNumber: '8080' );

Checking Project Health

Validate the project before running:

# Check for errors
mxcli docker check -p app.mpr

Use with OQL

Once the application is running, you can query it with OQL:

mxcli oql -p app.mpr "SELECT * FROM Sales.Customer"

See OQL Queries for details.

OQL Queries

The mxcli oql command executes OQL (Object Query Language) queries against a running Mendix runtime via the M2EE admin API. OQL is Mendix’s query language for retrieving data from the runtime, similar to SQL but operating on Mendix entities rather than database tables.

Usage

mxcli oql -p app.mpr "SELECT * FROM Sales.Customer"

Prerequisites

  • A Mendix application must be running (via mxcli docker run or another deployment)
  • The M2EE admin API must be accessible
  • The project file is needed to resolve entity and attribute names

Query Syntax

OQL uses Mendix entity names and attribute names, not database table names:

# Select all customers
mxcli oql -p app.mpr "SELECT * FROM Sales.Customer"

# Select specific attributes
mxcli oql -p app.mpr "SELECT Name, Email FROM Sales.Customer"

# Filter with WHERE
mxcli oql -p app.mpr "SELECT * FROM Sales.Order WHERE Status = 'Open'"

# Join entities via associations
mxcli oql -p app.mpr "SELECT o.OrderNumber, c.Name FROM Sales.Order o JOIN Sales.Customer c ON o.Sales.Order_Customer = c"

# Aggregation
mxcli oql -p app.mpr "SELECT COUNT(*) FROM Sales.Customer"

ORDER BY and null values

ORDER BY <attribute> DESC on an attribute that some rows leave empty does not return the newest rows first. Mendix emits the ordering to the database without a null placement, so the database’s default applies — and on PostgreSQL, DESC means NULLS FIRST:

-- what Mendix sends for `ORDER BY DealtAt DESC LIMIT 3`
SELECT "sales$game"."label" AS "label"
FROM "sales$game"
ORDER BY "sales$game"."dealtat" DESC
LIMIT $1

With two null DealtAt rows in the table, that returns both nulls and then one real row — so “give me the most recent 3” silently answers with rows that are not recent, stably across runs. The stability is what makes it convincing and wrong: a degenerate sort key is every bit as repeatable as a correct one.

Two things follow:

  • Check for nulls before concluding the ordering is broken. SELECT count(*) AS n FROM Sales.Game WHERE DealtAt = empty settles it in one query. Measured on Mendix 11.13.0 against PostgreSQL, ORDER BY on a DateTime is passed through correctly and orders correctly when the values are present — it is not ignored.
  • Add NULLS LAST when the attribute is optional, rather than falling back to ordering by id. Ordering by id answers a different question (insertion order), which happens to agree with recency only when nothing backdates a row.

Null placement is database-specific — SQL Server and Oracle differ from PostgreSQL — so a query relying on the default behaves differently per environment. Being explicit is the portable choice.

Difference from Catalog Queries

FeatureOQL (mxcli oql)Catalog (SELECT FROM CATALOG.*)
Data sourceRunning Mendix runtimeProject metadata (MPR file)
QueriesInstance data (rows)Model structure (entities, microflows)
RequiresRunning applicationOnly the MPR file
LanguageOQL (Mendix-specific)SQL (SQLite)
  • Use OQL to query actual data in a running application
  • Use catalog queries to analyze the project structure and metadata

Use Cases

Verifying Imported Data

After running IMPORT FROM, verify the data was imported correctly:

mxcli oql -p app.mpr "SELECT COUNT(*) FROM HR.Employee"
mxcli oql -p app.mpr "SELECT Name, Email FROM HR.Employee LIMIT 10"

Debugging Business Logic

Check data state while debugging microflows:

mxcli oql -p app.mpr "SELECT * FROM Sales.Order WHERE Status = 'Draft'"

Data Exploration

Explore data patterns in a running application:

mxcli oql -p app.mpr "SELECT Status, COUNT(*) FROM Sales.Order GROUP BY Status"

Dev Container Setup

mxcli supports development inside VS Code Dev Containers, providing a consistent, pre-configured development environment with all tools installed.

What Is a Dev Container?

A dev container is a Docker-based development environment defined by a devcontainer.json configuration file. When you open a project in VS Code with the Dev Containers extension, it builds and starts a container with all development tools pre-installed.

mxcli in Dev Containers

When running inside a dev container, mxcli tools and the mx binary are available at known paths:

ToolPath
mxcliAvailable on PATH
mx binary~/.mxcli/mxbuild/{version}/modeler/mx
mxbuild~/.mxcli/mxbuild/{version}/

Setting Up mxbuild

To download the correct mxbuild version for your project:

mxcli setup mxbuild -p app.mpr

This downloads mxbuild to ~/.mxcli/mxbuild/{version}/ and makes the mx tool available for validation and project creation.

Validating Projects

# Find the mx binary
MX=~/.mxcli/mxbuild/<version>/modeler/mx

# Check a project
$MX check /path/to/app.mpr

# Create a fresh project for testing
cd /tmp/test-workspace
$MX create-project

Project Initialization

Initialize mxcli for your project inside the dev container:

# Initialize with Claude Code support
mxcli init -p app.mpr

# This creates:
# - .claude/settings.json
# - .claude/commands/
# - .claude/lint-rules/
# - .ai-context/skills/
# - CLAUDE.md
# - VS Code MDL extension (auto-installed)

Container Runtime (Docker or Podman)

For mxcli docker build, mxcli docker run, and mxcli test (which require a container runtime), the dev container must have Docker-in-Docker or Podman-in-Podman support enabled.

Docker-in-Docker (default)

{
  "features": {
    "ghcr.io/devcontainers/features/docker-in-docker:2": {}
  }
}

Podman-in-Podman

For organizations that cannot use Docker Desktop due to licensing:

{
  "features": {
    "ghcr.io/devcontainers/features/podman-in-podman:1": {}
  },
  "containerEnv": {
    "MXCLI_CONTAINER_CLI": "podman"
  }
}

When running mxcli init, use --container-runtime podman to generate this configuration automatically. Requires Podman 4.7+ (ships podman compose with Docker Compose V2 compatibility).

Typical Dev Container Workflow

  1. Open the project in VS Code
  2. VS Code prompts to reopen in dev container
  3. Inside the container, run mxcli setup mxbuild -p app.mpr
  4. Run mxcli init to set up AI assistant integration
  5. Use mxcli commands as normal (REPL, exec, lint, test, etc.)

Connecting to a Running Studio Pro (MCP)

Experimental. The MCP backend targets Studio Pro’s embedded MCP server, introduced in Studio Pro 11.11. Tooling and transport details may change between Studio Pro versions — verify with mxcli mcp capabilities (below).

Normally mxcli edits a project by writing its .mpr file directly. With --mcp it instead routes every model write to a running Studio Pro over that editor’s embedded MCP server (“PED”), so your changes appear in the open model immediately — no save, no reload. Reads (SHOW/DESCRIBE) still come from the local .mpr you pass with -p (the same file Studio Pro has open).

This is the live-editing counterpart to the file backend. For the cost trade-offs of routing through MCP, see MCP Backend: Script-to-Tool-Call Cost.

What you need

  1. Studio Pro 11.11 or later, running, with the project open.
  2. The same project file passed to mxcli via -p — reads come from there, so it must match what Studio Pro has open.
  3. A reachable MCP endpoint. The server binds IPv6 loopback only ([::1]:7782 observed) and enforces a DNS-rebinding guard: the /mcp route only accepts requests whose HTTP Host header is exactly localhost. mxcli handles the Host header for you; you just have to make the TCP port reachable (see below).

The listening port can change between Studio Pro sessions. Confirm it on the machine running Studio Pro, e.g. lsof -nP -iTCP -sTCP:LISTEN | grep -i studio.

The flags

FlagPurpose
--mcp <url>Enable the MCP backend. The URL is what the server must see; its host drives the Host header and must stay localhost — e.g. http://localhost/mcp.
--mcp-dial <addr>The TCP address mxcli actually connects to, independent of the URL host. From a devcontainer this is typically host.docker.internal:<port>.
--mcp-checkAfter the command, run Studio Pro’s project-wide error check (ped_check_errors) and print the report. Needs Studio Pro 11.15+.
--mcp-runAfter the command, start the app in Studio Pro (the mendix_app handler) and print its URL. Needs Studio Pro 11.16+.
--mcp-verbosePrint each PED tool call the backend makes (to stderr).
--mcp-tracePrint each MDL command with the PED tool calls it makes (implies --mcp-verbose).

If --mcp-dial is omitted, mxcli connects to the URL’s own host first. For a localhost/127.0.0.1 URL it then falls back to host.docker.internal:<port> when that name resolves — the Docker host, which is where Studio Pro is when mxcli runs in a devcontainer. Loopback comes first because the Docker name resolves on more machines than containers: Docker Desktop for Windows adds it to the hosts file, pointing at the LAN address, where Studio Pro does not listen. With --mcp-dial given, that address is the only one tried.

From a devcontainer (the common case)

Studio Pro runs on your host; mxcli runs in the container. Because PED listens on IPv6 loopback on the host, the cleanest path is a tiny IPv4→IPv6 bridge on the host, which host.docker.internal can then reach.

Step 1 — bridge the port on the host (where Studio Pro runs). Forward an IPv4 port (here 7784) to PED’s IPv6 loopback (7782):

# Run this on the HOST, not in the container.
socat TCP4-LISTEN:7784,reuseaddr,fork 'TCP6:[::1]:7782'

Step 2 — verify the connection from the container with the capability report. This is the fastest way to confirm the handshake works and to see what the connected Studio Pro version can author:

mxcli mcp capabilities -p app.mpr \
  --mcp http://localhost/mcp \
  --mcp-dial host.docker.internal:7784

A successful run prints the server name/version, the authorable feature list, and the live tool set. If you instead see a connection error, jump to Troubleshooting.

Step 3 — apply a script against the live model:

mxcli exec mdl-examples/doctype-tests/01-mcp-domain-model-examples.mdl \
  -p app.mpr \
  --mcp http://localhost/mcp \
  --mcp-dial host.docker.internal:7784

The new entities, enumerations, and associations appear in Studio Pro as the script runs — no save or reload required.

The same --mcp / --mcp-dial flags work for a single statement (-c "…") and in the interactive REPL (mxcli -p app.mpr --mcp … with no subcommand) — in every mode, writes go to Studio Pro while reads come from the local .mpr.

Seeing the tool calls (--mcp-verbose / --mcp-trace)

Because each MDL statement expands into several PED tool calls, it’s useful to see what actually goes over the wire — for debugging, or just to understand the cost. Two levels, both printed to stderr (so they don’t pollute a piped stdout):

  • --mcp-verbose — one line per PED tool call, with its target document:

    → ped_create_module
    → ped_get_schema
    → ped_update_document McpDm
    → ped_check_errors McpDm
    
  • --mcp-trace — groups the calls under the MDL command that triggered them, and adds per-call detail (the update operations, schema types, read paths):

    ▸ create persistent entity McpDm.Customer
      → ped_read_document McpDm: /entities, /associations
      → ped_get_schema: DomainModels$Entity, DomainModels$Attribute
      → ped_update_document McpDm: add /entities
      → ped_get_schema: DomainModels$ValidationRule
      → ped_update_document McpDm: add /entities/0/validationRules ×2
      → ped_check_errors McpDm
    

    This makes the statement-to-tool-call mapping (and surprises like a read-back reconstructing a dirty module) visible at runtime.

Delete, check and run

Studio Pro’s own MCP server covers what used to need a second server:

  • Saving needs nothing: PED writes each change to disk as it lands.
  • DROP of a standalone document (enumeration, constant, microflow, nanoflow, page, workflow, business-event service) calls ped_delete_document, which Studio Pro 11.16 added. An older Studio Pro gets an error naming the version.
  • --mcp-check runs ped_check_errors over the whole project (11.15+).
  • --mcp-run starts the app through the mendix_app handler (11.16+), waits up to two minutes and prints its URL. A project with model errors is not run; the refusal is printed instead. The runtime log is read_file /app-logs/app.log on the same server.
mxcli exec changes.mdl -p app.mpr \
  --mcp http://localhost/mcp --mcp-dial host.docker.internal:7784 \
  --mcp-check --mcp-run

Same machine as Studio Pro

If mxcli runs on the host itself, point --mcp-dial at the loopback bridge directly and keep the localhost URL for the Host guard:

socat TCP4-LISTEN:7784,reuseaddr,fork 'TCP6:[::1]:7782'   # if PED is IPv6-only
mxcli exec changes.mdl -p app.mpr --mcp http://localhost/mcp --mcp-dial 127.0.0.1:7784

What works over MCP

The MCP backend authors a subset of MDL — entities (incl. extends, ALTER, and NOT NULL / UNIQUE), associations, enumerations, constants, microflows, pages (CREATE + ALTER), and workflows. Some document types (nanoflows, Java actions, business-event services, view entities), security, navigation, and MOVE are not authorable over MCP today. Reads of any document type always work (they come from the local .mpr).

mxcli mcp capabilities -p app.mpr --mcp … --mcp-dial … reports exactly what your connected version supports. See also Capabilities Overview.

Troubleshooting

SymptomLikely cause / fix
connection refused on dialThe bridge isn’t running, or the port is wrong. Re-run socat on the host and confirm PED’s port with lsof (it changes per session).
Request rejected / 403-style errorThe Host header guard. Keep the URL host localhost (http://localhost/mcp) and redirect the connection with --mcp-dial — never put the container/host address in the URL itself.
Reads don’t reflect a change you just madeExpected for some edits until the project is saved — reads of an unchanged doc come from the local .mpr. A module you just wrote is reconstructed live.
DROP / --mcp-check / --mcp-run reports it needs a newer Studio ProDROP and --mcp-run need Studio Pro 11.16+, --mcp-check 11.15+. On 11.16.0, ped_delete_document fails with Session 'mcp-server' not found for callers outside Studio Pro’s own agent session; delete the document in Studio Pro.
Intermittent timeoutsThe transport can be flaky across a bridge; a timed-out write may still have applied. Re-run mxcli mcp capabilities to re-establish, and prefer idempotent scripts.

See also

Working Outside Studio Pro

A project edited with mxcli, git and an editor is sometimes opened in Studio Pro afterwards. Several kinds of state that Studio Pro depends on are invisible to mx check, and mxcli checks them.

The ContentsHash index: mxcli fix hashes

In an MPR v2 project (Mendix 10.18 and later) the .mpr file indexes every mprcontents/**/*.mxunit file by its hash: Unit.ContentsHash is base64(SHA-256(file)). mxcli’s writer and Studio Pro keep the two in step. Anything else that changes a .mxunit does not: restoring a unit with git checkout, git restore, a revert or a merge leaves the index describing bytes that are no longer on disk. mx check and MxBuild read the files and do not notice; Studio Pro uses the index to decide what changed.

mxcli fix hashes -p app.mpr            # verify; exits 1 when something is off
mxcli fix hashes -p app.mpr --repair   # rewrite mismatched hashes from the files
ReportedMeaning--repair
MISMATCHthe file’s hash differs from the stored onerewrites the stored hash from the file
MISSINGthe .mpr indexes a unit whose file does not existreported only — restore the file
ORPHANa .mxunit file no unit in the .mpr indexesreported only — mxcli diag --check-units --fix removes it

The repair treats the files as the source of truth and never changes them. It updates every hash in one SQLite transaction, and, like every mxcli write, is refused while Studio Pro has the project open (the .mpr.lock beside it).

An MPR v1 project (below Mendix 10.18) keeps each unit’s contents inside the .mpr, in the same row as its ContentsHash, and Studio Pro fills the hash there too, with the same formula over the stored contents. No file can drift from it, but mxcli before this release replaced a unit’s contents and left the old hash in place, or stored none for a new unit. On v1 the command compares each hash with the contents beside it, reports MISMATCH only, and --repair rewrites the hash from the contents.

mxcli exec and mxcli docker check run the verify too and print a one-line warning when it finds a mismatch or a missing file. It never fails them; measured on a 900-unit project, the whole verify takes about 0.2 s. They skip MPR v1, where the verify reads every unit’s contents.

Property names Studio Pro’s merge compares: mxcli fix property-sets

Mendix’s diff and merge engine compares two revisions of an element by its property names before any value, and throws when they differ:

Objects with ID … of type Microflows$RestOperationCallAction do not have the same properties.

The model is valid — mx check, the build and the runtime fill an absent property with its default — but mx diff, merging and Studio Pro’s Version Control panel fail on it once Studio Pro has saved the document with its full property set. Every mxcli write gives a unit the property set Studio Pro writes for the project’s Mendix version. A unit written by an older mxcli and not rewritten since keeps its gaps; this command reaches those.

mxcli fix property-sets -p app.mpr --dry-run   # list the units that would change; exits 1 when one would
mxcli fix property-sets -p app.mpr             # write them

Each unit’s report counts the keys added (with the value Mendix gives an absent one) and the empty keys stripped (properties the project’s version does not declare yet). A project user role with no GUID — older mxcli wrote them without one — gets its own $ID as its GUID, the value a role mxcli creates now gets; every mxcli write of the security settings does the same. Without it the build invents a random GUID each time, so the runtime can only match the role’s database row by name. A role that already has a GUID keeps it. Nothing else changes: no element $ID moves, and no existing GUID is touched. A second run writes nothing.

The filled GUID protects a later rename of the role only in a database first synchronised after the fill. A database synchronised before it keeps the old random GUID and still finds the role by name, so renaming the role there still deletes its row and the user assignments that are not demo users. A unit holding a value in a key its version does not declare is reported and left alone, and, like every mxcli write, the command is refused while Studio Pro has the project open.

The values the command adds were measured with Mendix’s own tools on specific Mendix versions. A key that no measurement covers on your version is not filled, but the command still finds it. Studio Pro writes the same property names on every element of a type, so the command also lists each name that some elements of a type carry and others lack:

  MIXED     Microflows$Nanoflow.ExportLevel: on 14 of 15 element(s); without it: 71e6faf6-… [Microflows$Nanoflow ACT_Login]

It leaves those names as they are and exits 1. Report them with your Mendix version so the value can be measured.

The repair reaches forward, not back

A merge compares each document both branches changed with its revision in the merge base, the commit the two branches share. The repair completes the property names from its own commit on, and so does any Studio Pro save of the document; the revisions before it in history keep their gaps. A merge or rebase whose base is one of those revisions still fails on every such document, whichever branch carries the full set. Measured with mx merge, Studio Pro’s merge engine, on 11.13.0, for an access rule an mxcli before 0.26 created: the exception when the other branch had been saved by Studio Pro, the same exception when it had been repaired by this command, and a clean merge once the repaired revision is the base.

So run the repair once, on the main branch, in a commit of its own, when few branches are open, and start new branches from it. An older branch still merges cleanly unless it changed a document that was incomplete in its merge base; that branch needs the steps below.

Getting past a merge that fails with “do not have the same properties”

Studio Pro looks for an unfinished merge or rebase when it opens a project and runs it again, so a project whose merge fails does not open until the merge is aborted. The way through is to make your branch stop changing the documents the merge cannot compare, merge, and redo your edits to them afterwards.

  1. Abort from the command line: git merge --abort, or git rebase --abort.

  2. Find the documents. The exception names only the first element it meets. The merge cannot compare a document that an older mxcli left incomplete in the merge base and that both branches changed since:

    upstream=origin/main                     # the branch you are merging
    base=$(git merge-base HEAD $upstream)
    git worktree add ../merge-base $base
    mxcli fix property-sets -p ../merge-base/app.mpr --dry-run   # NEEDS <unit id> [type]
    git worktree remove ../merge-base
    comm -12 <(git diff --name-only $base HEAD -- mprcontents | sort) \
             <(git diff --name-only $base $upstream -- mprcontents | sort)
    

    The units in both lists are the ones to take back. A unit’s file is mprcontents/<first two>/<next two>/<unit id>.mxunit.

  3. Take your edits back. Note what you changed in those documents (mxcli diff-local -p app.mpr --ref $base..HEAD prints it as MDL), then put each one back to its merge-base version, re-index it, and commit:

    git checkout $base -- mprcontents/01/31/0131b342-dc00-4619-9e90-64c9de7e2a50.mxunit
    mxcli fix hashes -p app.mpr --repair     # the .mpr indexes every unit by its hash
    git commit -am "Take back edits the merge cannot compare"
    
  4. Merge again in Studio Pro. Your branch no longer changes those documents, so the merge takes the other branch’s version without comparing them. Merge rather than rebase: a rebase replays your commits one at a time, and the first one that changed such a document fails again.

  5. Redo the edits in Studio Pro, or by running the MDL from step 3, and commit. That revision carries the full property set, so a merge based on it or on anything later is unaffected.

Never get past the error by deleting and re-creating an entity, attribute or association: the new element gets a new GUID, and on the next deployment the runtime drops the old one’s table or column, with its data.

Measured with mx merge on 11.13.0 (testdata/pedapp, an access rule an mxcli before 0.26 created, and both branches editing the same domain model): the merge fails with the exception before step 3 and succeeds after it, the merged project keeps both branches’ other changes, and mx check reports exactly what it reports on the untouched project.

Git states that crash Studio Pro

Studio Pro 11.13 fails to open a project with “Unable to find ‘system’ property in ‘system’” when the project folder is a git repository and

  • the checked-out branch has no upstream — remedy: git push -u origin <branch> before opening the project (or git remote add origin <url> first, if the repository has no remote); or
  • git reports “detected dubious ownership” — typical for a folder shared between the host and a devcontainer, owned by a different user on each side. Remedy, on the machine that runs Studio Pro: git config --global --add safe.directory <path>.

mxcli docker check, mxcli run --local and mxcli diag -p app.mpr warn about both. The warnings are advice and never fail a command. They are silent:

  • outside a git repository, or when git is not installed;
  • on a detached HEAD — how CI systems and git checkout <sha> leave a repository; there is no branch to push, and nobody opens such a checkout in Studio Pro;
  • when the CI environment variable is set, or MXCLI_NO_GIT_WARNINGS=1.

Dubious ownership is judged by the git mxcli runs. Inside a devcontainer that is the container’s git, which may see ownership differently from the host’s Studio Pro — so a clean report from inside the container does not rule it out on the host.

One report: mxcli diag -p

mxcli diag -p app.mpr

prints mxcli’s diagnostics followed by a Project section with both checks.

One commit, all the steps: mxcli git commit

Studio Pro’s Commit does more than git commit: it keeps the ContentsHash index in step, stages exactly the files a Mendix repository should ship, and attaches the mx_metadata note. mxcli git commit is that sequence for commits made outside Studio Pro:

mxcli git commit -m "Add order history page" -p app.mpr
mxcli git commit -m "Fix stock filter" -p app.mpr --push
mxcli git commit -m "WIP" -p app.mpr --dry-run

In order it:

  1. repairs ContentsHash mismatches from the files on disk (MISSING and ORPHAN entries have no automatic repair and refuse the commit);
  2. stages everything, then unstages the files Studio Pro never commits: project-settings.user.json, *.mpr.lock, *.mpr.bak, and the project’s own .mendix-cache/, deployment/, theme-cache/ and .mxcli/, the folders Studio Pro, mxbuild and mxcli create beside the .mpr. A folder of the same name further down, such as resources/deployment/, is committed;
  3. runs git commit with your message and your git identity;
  4. computes the mx_metadata note from the new commit and attaches it, and prints the git push origin <branch> refs/notes/mx_metadata command — --push runs it for you, because the branch and the notes ref must travel together.

The commit is refused while Studio Pro has the project open (the .mpr.lock beside it), and during an unresolved merge — git add -A would otherwise commit the conflict markers. --dry-run verifies and reports (including ContentsHash drift, which it does not repair — a dry run writes nothing), and reports what it would commit without committing. It stages into a copy of the index, so whatever you had staged is left exactly as it was.

Merging branches: mxcli git merge

git merge cannot merge Mendix models: the .mpr is SQLite and every .mxunit is BSON, so two sides changing the model always meet a binary CONFLICT (content) — there is nothing for git’s textual merge to work on. Studio Pro’s Merge Changes Here resolves it with a model-aware merge, and that engine ships as a command: mx merge BASE MINE THEIRS over the three revisions’ project files (it runs on Linux devcontainers too).

mxcli git merge <ref> puts the two together:

mxcli git merge feature/orders -p app.mpr
mxcli git merge origin/main -p app.mpr --push

git merge <ref> runs first; a merge git resolves itself (disjoint files, a fast-forward) is annotated with the mx_metadata note like mxcli git commit does. On a model-file conflict the base, ours (a clean checkout of HEAD) and theirs revisions are checked out into temporary worktrees and the three projects’ .mpr files go to mx merge (resolved for the project’s Mendix version; MINE is never the working tree mid-merge — the engine chokes on the conflicted state); the engine writes its result into MINE and it is copied back, so the engine owns the .mpr:

  • what the engine auto-resolves is copied back, hash-checked, staged and committed as the normal two-parent merge commit, with the note attached. The engine’s .mpr carries both sides’ document sets, so a unit one side added while the other changed existing ones is kept, not dropped;
  • a conflict the engine reports as ManualResolutionRequired (exit 2) is never merged silently: the merge is aborted, and both sides’ changes are printed for review — at property level, from mx diff (“New type > Length: 100 → 150” on ours, “→ 200” on theirs). Then decide: --plan <file> resolves per unit from the plan template written beside the .mpr (<mpr>.merge-plan.json, which mxcli git commit never stages): set "take" to ours/theirs per unit. The engine runs again and the plan decides only the units it left open — everything the engine merged stays merged, so the template names just those units, and a plan that names more or fewer (stale, or edited by hand) is refused. Each planned unit is taken whole from its side, its conflict marker cleared (Studio Pro reports a marker left in the index as CE0137), and the .mpr index repaired from the files (modified-on-both units only; added/deleted/moved conflicts go to Studio Pro). --ours or --theirs takes one side of every conflicted unit, hash-gated: when the sides’ document sets differ (one side added or deleted a unit), committing a whole side would orphan the other side’s documents, so the commit is refused and the merge aborted. Any other engine exit is mx failing, not a decision: it is reported and the merge is aborted cleanly. Resolutions that must mix both sides inside one unit stay in Studio Pro (Version Control › Merge Changes Here);
  • a conflict in non-model files (text sources) is left in place for manual resolution, like plain git: resolve them, git add them, and re-run the command — it finishes the merge (model conflicts included) and commits. When that re-run needs a model decision and aborts, your text resolutions are recorded in the plan template and --plan puts them back; --plan stops before it touches the model while a text conflict is still open.

Merging is still never done by plain git merge on a Mendix repository: use mxcli git merge or Studio Pro, never a textual merge of mprcontents/. Like every mxcli write, the command is refused while Studio Pro has the project open.

Auditing a repository: mxcli git doctor

A repository that has known only plain git misses several things Studio Pro wrote when it committed, and they are invisible one by one. mxcli git doctor reports all of them in one read-only pass; --fix repairs:

mxcli git doctor                       # report only; exits 1 while an issue remains
mxcli git doctor -p app.mpr --fix      # repair, computing real notes from the model
mxcli git doctor --fix --version 10.21.0
Check--fix
local git config: core.autocrlf=False (a checkout must not rewrite line endings inside .mxunit files; any other value is rewritten), mendix.commits-since-gc, mendix.lineEndingResetDone — the mendix.* values are Studio Pro’s own bookkeeping and are only backfilled when missingsets/backs them
the remote URL as configured: Studio Pro can only use HTTPSrewrites SSH (git@host:org/app.git, ssh://…) to HTTPS; other non-HTTPS URLs are reported unchanged. A url.<base>.insteadOf rule in your git config that makes git use another URL is reported, and left alone
the remote’s refs/notes/mx_metadatareported only — push the notes ref together with the branch
the local refs/notes/mx_metadata holds the remote’s notes: git clone and a plain git fetch never fetch them, so a fresh clone or a CI checkout has nonefetches them, as a fast-forward only; refused when the local ref has notes the remote does not
each commit on the checked-out branch and its mx_metadata note: missing notes make Studio Pro show (unknown); a malformed note it cannot read at allcomputes real notes from the model (-p), falling back to a template note with a --version ModelerVersion where the model at a commit cannot be read (MPR v1 history, a revision without the .mpr); when no version is known the newest valid note in history supplies it

Notes Studio Pro wrote are never touched, with or without --fix. --fix does not push.

The commits are judged only once the local notes ref holds the remote’s. Until then the report names the unfetched ref and the command that fetches it, rather than calling every commit the remote annotates unannotated. In CI, fetch the notes before the audit:

git fetch origin refs/notes/mx_metadata:refs/notes/mx_metadata
mxcli git doctor -p app.mpr

Commit metadata: mxcli git note

Studio Pro attaches a git note under refs/notes/mx_metadata to every commit it makes:

{"BranchName":"feature-x","ModelerVersion":"11.13.0",
 "ModelChanges":[{"Status":"Modified","UnitID":"…","UnitType":"Forms$Page",
                  "UnitName":"Home_Web","Module":"MyModule"}],
 "RelatedStories":[],"SolutionVersion":"","MPRFormatVersion":"Version2","HasModelerVersion":true}

It reads the note to check that a revision’s Mendix version is compatible and to show what the revision changed. A commit made with plain git has none, and Studio Pro 11.13 back-fills one with "(unknown)" values and no changes.

mxcli git note -p app.mpr            # preview the notes for commits not yet pushed
mxcli git note -p app.mpr --write    # attach them
git push origin <branch> refs/notes/mx_metadata

The note is computed from the commit: the .mpr unit index and the mprcontents units at the commit and at its parent. With no commit argument it covers @{upstream}..HEAD, or HEAD when the branch has no upstream. A note Studio Pro wrote is kept unless you pass --force; a placeholder is replaced.

StatusMeaning
Added / Deletedthe unit is new / gone. A deleted module is listed once, as its Projects$ModuleImpl
Modifiedthe unit’s content changed. A re-serialisation that only mints new element IDs is not a change
Movedthe unit moved to another folder (its container in the .mpr changed)

Measured against Studio Pro’s own notes on a Team Server repository: for ordinary commits the note is identical, header and change list. For a Mendix version upgrade the header is identical but the change list is longer than Studio Pro’s, because an upgrade re-serialises units in ways only Studio Pro knows to ignore. Upgrades are done in Studio Pro, which writes its own note. MPR v1 projects are not supported.

Push the notes ref soon. git push does not push notes, and while the project is open Studio Pro fetches every few minutes and force-replaces the local refs/notes/mx_metadata with the Team Server’s copy, discarding notes that were not pushed. If that happens, mxcli git note --write again: the discarded notes have become placeholders, which it replaces.

The whole workflow (server branches, what to stage, never merging model history with git) is in the teamserver-git skill that mxcli init installs.

VS Code Extension

The MDL extension for VS Code provides a rich editing experience for .mdl files, bringing IDE-level support for Mendix Definition Language. The extension communicates with the mxcli lsp language server to provide real-time diagnostics, code completion, hover documentation, and navigation.

Feature Summary

FeatureDescription
Syntax highlightingMDL keyword, string, comment, and identifier coloring
Parse diagnosticsReal-time error reporting as you type
Semantic diagnosticsReference validation on save (entities, microflows, pages)
Code completionContext-aware keyword and snippet suggestions
HoverView MDL definitions by hovering over Module.Name references
Go-to-definitionCtrl+click to open element source as a virtual document
Document outlineNavigate MDL statements via the Outline panel
FoldingCollapse statement blocks and widget trees
Context menuRun File, Run Selection, and Check File commands
Project treeBrowse modules and documents in a Mendix-specific tree view

How It Works

The extension spawns mxcli lsp --stdio as a background process and communicates via the Language Server Protocol over standard I/O. This means all analysis, completion, and navigation is performed by the same engine that powers the mxcli command line.

┌──────────────┐     stdio      ┌──────────────────┐     ┌────────────┐
│  VS Code     │◀──────────────▶│  mxcli lsp       │────▶│  .mpr file │
│  Extension   │   LSP JSON-RPC │  --stdio          │     │            │
└──────────────┘                └──────────────────┘     └────────────┘

Requirements

  • VS Code 1.80 or later (or any VS Code fork such as Cursor)
  • mxcli binary on PATH or configured via the mdl.mxcliPath setting
  • A Mendix project (.mpr file) for semantic features

Settings

SettingDefaultDescription
mdl.mxcliPathmxcliPath to the mxcli executable
mdl.mprPath(auto-discovered)Path to .mpr file; if empty, the extension searches the workspace

Getting Started

The fastest way to get started is mxcli init, which automatically installs the extension into your project. See Installation for details, or jump to specific features:

Installation

There are three ways to install the VS Code MDL extension.

Running mxcli init on a Mendix project automatically installs the VS Code extension along with skills, commands, and configuration files:

mxcli init /path/to/my-mendix-project

The command copies the bundled .vsix file into the project and installs it into VS Code. This is the recommended approach because it also sets up the full AI-assisted development environment.

Option 2: Manual VSIX Install

If you already have the VSIX file (e.g., from the mxcli build artifacts), install it directly:

code --install-extension vscode-mdl/vscode-mdl-*.vsix

Or from the VS Code Command Palette:

  1. Open Extensions: Install from VSIX… (Ctrl+Shift+P)
  2. Navigate to the .vsix file
  3. Click Install

Option 3: Build from Source

If you are developing or modifying the extension:

# Requires bun (not npm/node)
cd vscode-mdl
bun install
bun run compile

# Package as VSIX
make vscode-ext

# Install the built VSIX
make vscode-install

Note: The VS Code extension build toolchain uses bun, not npm or node. The Makefile targets make vscode-ext and make vscode-install handle this automatically.

Verifying the Installation

After installation, open any .mdl file in VS Code. You should see:

  1. Syntax highlighting – MDL keywords like CREATE, SHOW, and DESCRIBE are colored
  2. Parse diagnostics – Syntax errors appear as red underlines immediately
  3. Status bar – The MDL language server status appears in the bottom bar

Configuring the Extension

Open VS Code settings (Ctrl+,) and search for “MDL”:

SettingDefaultDescription
mdl.mxcliPathmxcliPath to the mxcli binary. Set this if mxcli is not on your PATH.
mdl.mprPath(empty)Path to the .mpr file. If empty, the extension auto-discovers it by searching the workspace root.

Example settings.json:

{
  "mdl.mxcliPath": "./mxcli",
  "mdl.mprPath": "./app.mpr"
}

Dev Container Setup

When using mxcli init, a .devcontainer/ configuration is also created. Opening the project in VS Code and choosing Reopen in Container gives you a sandboxed environment with:

ComponentPurpose
mxcliMendix CLI binary (copied into the project)
MxBuild / mxProject validation and building
JDK 21Required by MxBuild
Docker-in-DockerRunning Mendix apps locally
Node.jsPlaywright testing support
PostgreSQL clientDatabase connectivity
Claude CodeAI coding assistant (auto-installed)

The VS Code extension is automatically available inside the dev container.

Syntax Highlighting and Diagnostics

The VS Code extension provides three layers of language feedback: syntax highlighting, parse diagnostics, and semantic diagnostics.

Syntax Highlighting

MDL files (.mdl) receive full syntax coloring based on a TextMate grammar:

Token TypeExamplesColor Theme Category
Language headermdl 1;keyword.control.directive
KeywordsCREATE, LIST, DESCRIBE, ALTER, GRANTkeyword
TypesString, Integer, Boolean, DateTimesupport.type
Strings'Hello World'string
Numbers100, 3.14constant.numeric
Comments-- line comment, /** doc comment */comment
IdentifiersMyModule.Customervariable
Operators=, !=, AND, ORkeyword.operator

Highlighting works immediately on file open – no project or language server connection is required.

Parse Diagnostics (Real-Time)

As you type, the extension sends the file content to the MDL language server, which runs the ANTLR4 parser and reports syntax errors in real time. These appear as:

  • Red underlines on the offending tokens
  • Error entries in the Problems panel (Ctrl+Shift+M)
  • Inline hover messages when you point at the underlined text

Parse diagnostics catch issues like:

  • Missing semicolons
  • Unmatched parentheses or braces
  • Invalid keyword combinations
  • Malformed qualified names

Example error:

Line 3, Col 42: mismatched input ')' expecting ','

Parse diagnostics run on every keystroke (debounced) and do not require a Mendix project file.

Language Version Diagnostics

The mdl 1; header a script starts with sets the language version it is checked against. Deprecated spellings (MDL-DEPR*) and, in a script without the header, constructs whose meaning mdl 1 changes (MDL-V1-*) are underlined as warnings. Hover over one for mxcli help <code>, and use the quick fix (Ctrl+.) to apply the fmt --upgrade rewrite. See LSP Capabilities.

Semantic Diagnostics (On Save)

When you save a .mdl file, the language server performs deeper validation against the actual Mendix project (.mpr file). Semantic diagnostics check:

  • Entity references – does MyModule.Customer actually exist?
  • Attribute references – does the entity have the named attribute?
  • Microflow references – do called microflows exist with correct signatures?
  • Association references – are FROM/TO entities valid?
  • Module existence – does the referenced module exist in the project?

Semantic diagnostics require:

  1. A valid mdl.mprPath setting (or auto-discovered .mpr file)
  2. The mxcli lsp server to be running

These diagnostics appear as warnings or errors in the Problems panel, identical in appearance to parse errors but typically with more descriptive messages referencing the project state.

Diagnostic Severity Levels

SeverityMeaning
ErrorSyntax error or invalid reference that would prevent execution
WarningValid syntax but potentially problematic (e.g., unused variable)
InformationSuggestion or style recommendation

Equivalent CLI Commands

The same checks available in the extension can be run from the command line:

# Syntax check only (--no-project: do not auto-detect an .mpr in this directory)
mxcli check script.mdl --no-project

# Syntax + reference validation
mxcli check script.mdl -p app.mpr --references

Completion, Hover, Go-to-Definition

The VS Code extension provides IntelliSense features powered by the MDL language server (mxcli lsp --stdio).

Code Completion

Press Ctrl+Space or start typing to trigger context-aware completions:

Keyword Completion

At the start of a statement, the extension suggests top-level MDL keywords:

CREATE  LIST  DESCRIBE  ALTER  DROP  GRANT  REVOKE  SEARCH  REFRESH  ...

Completion offers mdl 1 spellings only: no deprecated alias is suggested.

After a keyword, context-appropriate follow-up keywords appear:

CREATE |
       ├── ENTITY
       ├── ASSOCIATION
       ├── ENUMERATION
       ├── MICROFLOW
       ├── PAGE
       ├── SNIPPET
       ├── MODULE
       ├── MODULE ROLE
       ├── USER ROLE
       └── WORKFLOW

Snippet Completion

Common patterns are offered as multi-line snippets. For example, typing entity and selecting the snippet inserts:

CREATE PERSISTENT ENTITY ${1:Module}.${2:EntityName} (
    ${3:Name}: ${4:String(200)}
);

The extension also contributes editor snippets that start a new script with the language header: mdl inserts mdl 1;, and script / script microflow insert a small mdl 1 script.

Reference Completion

When the language server has a loaded project, qualified name references offer completions from the actual model:

  • Entity names after FROM, TO, and in data source expressions
  • Module names after IN and as the first part of qualified names
  • Attribute names inside entity contexts
  • Microflow names after CALL MICROFLOW

Hover

Hover over any Module.Name qualified reference to see its MDL definition in a tooltip. For example, hovering over Sales.Customer in:

RETRIEVE $c FROM Sales.Customer WHERE ...

Displays the entity definition:

CREATE PERSISTENT ENTITY Sales.Customer (
    Name: String(200) NOT NULL,
    Email: String(200),
    IsActive: Boolean DEFAULT true
);

Hover works for entities, microflows, pages, enumerations, associations, and snippets.

Go-to-Definition

Ctrl+Click (or F12) on a qualified reference opens the element’s MDL source as a virtual read-only document. The definition is generated on-the-fly by describing the element from the project.

For example, Ctrl+clicking Sales.ProcessOrder opens a virtual document showing the full microflow definition with all its activities, conditions, and flows.

This also works from the terminal: qualified names like MyModule.EntityName in mxcli output are rendered as clickable links that open the definition.

Document Symbols and Outline

The Outline panel (View > Outline) shows all MDL statements in the current file as a navigable tree:

├── CREATE ENTITY Sales.Customer
├── CREATE ENTITY Sales.Order
├── CREATE ASSOCIATION Sales.Order_Customer
├── CREATE MICROFLOW Sales.ProcessOrder
└── CREATE PAGE Sales.CustomerOverview

Click any entry to jump to that statement in the editor.

Folding

Statement blocks can be collapsed:

  • CREATE MICROFLOW ... BEGIN ... END; – fold the microflow body
  • CREATE PAGE ... { ... } – fold the widget tree
  • IF ... THEN ... END IF; – fold conditional blocks
  • LOOP ... BEGIN ... END LOOP; – fold loop bodies
  • /** ... */ – fold documentation comments

Project Tree

The VS Code extension provides a Mendix Project Tree view that mirrors the App Explorer in Mendix Studio Pro, allowing you to browse modules and documents without leaving VS Code.

Accessing the Tree

The project tree appears in the Explorer sidebar under the “Mendix Project” section. If it does not appear, check that:

  1. The extension is installed and enabled
  2. A .mpr file is present in the workspace (or configured via mdl.mprPath)
  3. The mxcli binary is accessible

Tree Structure

The tree is organized by module, with documents grouped by type:

Mendix Project
├── MyFirstModule
│   ├── Domain Model
│   │   ├── Customer
│   │   ├── Order
│   │   └── Product
│   ├── Microflows
│   │   ├── ACT_Customer_Create
│   │   ├── ACT_Customer_Delete
│   │   └── ProcessOrder
│   ├── Pages
│   │   ├── Customer_Overview
│   │   ├── Customer_NewEdit
│   │   └── Order_Overview
│   ├── Snippets
│   │   └── NavigationMenu
│   ├── Enumerations
│   │   └── OrderStatus
│   └── Nanoflows
│       └── NF_ValidateInput
├── Administration
│   └── ...
└── System
    └── ...

Interactions

  • Click a document to open its MDL source as a virtual read-only document (equivalent to DESCRIBE in the REPL)
  • Expand a module to see all document types
  • Expand a document type to see individual documents

Refreshing

The tree refreshes automatically when the project file changes. You can also trigger a manual refresh from the tree view toolbar.

Relationship to SHOW Commands

The tree is backed by the same metadata that powers the SHOW commands:

Tree LevelEquivalent MDL Command
Module listLIST MODULES
Entities in a moduleLIST ENTITIES IN MyModule
Microflows in a moduleLIST MICROFLOWS IN MyModule
Pages in a moduleLIST PAGES IN MyModule
Full structureDESCRIBE STRUCTURE IN MyModule DEPTH 2

Context Menu Commands

The VS Code extension adds three commands to the editor context menu (right-click) for .mdl files. These commands provide quick access to running and validating MDL without switching to a terminal.

Run File

Right-click > MDL: Run File

Executes the entire .mdl file against the configured Mendix project. This is equivalent to:

mxcli exec script.mdl -p app.mpr

Output appears in the integrated terminal. Any errors from execution (missing references, invalid operations) are displayed inline.

Check File

Right-click > MDL: Check File

Validates the file’s syntax without executing it. This is equivalent to:

mxcli check script.mdl

If a project is configured, reference validation is also performed:

mxcli check script.mdl -p app.mpr --references

Check results appear in the Problems panel alongside the live diagnostics from the language server.

Run Selection

Right-click > MDL: Run Selection

Executes only the currently selected text as MDL. This is useful for:

  • Running a single CREATE statement from a larger script
  • Testing a SHOW or DESCRIBE command interactively
  • Executing a REFRESH CATALOG before running queries

The selection must contain one or more complete MDL statements (terminated by ;).

Keyboard Shortcuts

The commands do not have default keyboard shortcuts, but you can bind them in VS Code:

  1. Open Keyboard Shortcuts (Ctrl+K Ctrl+S)
  2. Search for “MDL”
  3. Assign your preferred key bindings

Terminal Integration

All commands use the VS Code integrated terminal. Output from mxcli includes clickable links for qualified names – Ctrl+click on MyModule.EntityName in the terminal output to open the element’s definition.

LSP Server

The MDL language server implements the Language Server Protocol (LSP), enabling any editor with LSP support to provide rich MDL editing features. The server is built into the mxcli binary – no separate installation is needed.

Overview

The language server is started with:

mxcli lsp --stdio

It communicates over standard I/O using JSON-RPC, the standard LSP transport. The server provides:

  • Real-time diagnostics from the ANTLR4 parser
  • Semantic diagnostics validated against the Mendix project
  • Code completion with keywords, snippets, and project references
  • Hover showing MDL definitions for qualified names
  • Go-to-definition opening element source as virtual documents
  • Document symbols for outline and breadcrumb navigation
  • Folding ranges for collapsible statement blocks

Architecture

┌──────────────┐                    ┌─────────────────────┐
│    Editor     │◀── LSP JSON-RPC ─▶│   mxcli lsp         │
│  (VS Code,   │     over stdio     │                     │
│   Neovim,    │                    │  ┌───────────────┐  │
│   Emacs)     │                    │  │ ANTLR4 Parser │  │
└──────────────┘                    │  └───────────────┘  │
                                    │  ┌───────────────┐  │
                                    │  │ MPR Reader    │  │
                                    │  └───────────────┘  │
                                    │  ┌───────────────┐  │
                                    │  │ Catalog       │  │
                                    │  └───────────────┘  │
                                    └─────────────────────┘

The server is implemented in cmd/mxcli/lsp.go. It reuses the same ANTLR4 parser, MPR reader, and catalog components that power the mxcli CLI.

Project Discovery

On startup, the server auto-discovers the Mendix project:

  1. Check the mdl.mprPath client configuration
  2. Search the workspace root for *.mpr files
  3. If found, load the project for semantic features

If no project is found, the server still provides syntax highlighting and parse diagnostics, but semantic features (reference validation, hover definitions, completion from project) are disabled.

Protocol

The MDL language server uses the standard LSP stdio transport: JSON-RPC messages are exchanged over the process’s standard input and standard output.

Starting the Server

mxcli lsp --stdio

The --stdio flag is required. The server reads LSP requests from stdin and writes responses to stdout. Diagnostic logging goes to stderr.

Initialization Handshake

The client initiates the connection with an initialize request. The server responds with its capabilities:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "capabilities": {},
    "rootUri": "file:///path/to/project",
    "initializationOptions": {
      "mprPath": "app.mpr"
    }
  }
}

The initializationOptions.mprPath is optional. If provided, the server uses it to locate the Mendix project. Otherwise, it searches the rootUri for .mpr files.

The server responds with:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "capabilities": {
      "textDocumentSync": 1,
      "completionProvider": { "triggerCharacters": [".", " "] },
      "hoverProvider": true,
      "definitionProvider": true,
      "documentSymbolProvider": true,
      "foldingRangeProvider": true,
      "diagnosticProvider": {
        "interFileDependencies": false,
        "workspaceDiagnostics": false
      }
    }
  }
}

After receiving the response, the client sends initialized to complete the handshake.

Document Synchronization

The server uses full document sync (textDocumentSync: 1). On every change, the client sends the complete document content:

  • textDocument/didOpen – file opened, triggers initial parse
  • textDocument/didChange – content changed, triggers re-parse and diagnostics
  • textDocument/didSave – file saved, triggers semantic validation
  • textDocument/didClose – file closed, clears diagnostics

Diagnostics

Diagnostics are pushed from the server to the client via textDocument/publishDiagnostics notifications. Two levels of diagnostics are reported:

  1. Parse diagnostics – sent after every didChange, based on ANTLR4 parser errors
  2. Semantic diagnostics – sent after didSave, based on reference validation against the project

Shutdown

The client sends shutdown followed by exit to terminate the server cleanly.

Capabilities

The MDL language server supports the following LSP capabilities.

Supported Methods

LSP MethodFeatureNotes
textDocument/publishDiagnosticsParse and semantic error reportingPush-based; parse on change, semantic on save
textDocument/completionCode completionKeywords, snippets, and project references
textDocument/hoverHover documentationMDL definitions for qualified names; mxcli help <code> on a migration warning
textDocument/codeActionQuick fixesThe fmt --upgrade rewrite for a migration warning
textDocument/definitionGo-to-definitionOpens element source as virtual document
textDocument/documentSymbolDocument outlineAll MDL statements in the file
textDocument/foldingRangeCode foldingStatement blocks, widget trees, comments

Document Synchronization

MethodSupported
textDocument/didOpenYes
textDocument/didChangeYes (full sync)
textDocument/didSaveYes
textDocument/didCloseYes

Completion Details

Trigger characters: . and (space).

Completion items include:

  • Keywords – CREATE, LIST, DESCRIBE, ALTER, DROP, GRANT, etc. Only mdl 1 spellings are offered: a keyword the grammar accepts only as a deprecated alias (SHOW_PAGE, DELETE_BEHAVIOR, DEFINE, …) is not, and the listings follow LIST, not SHOW.
  • Keyword sequences – PERSISTENT ENTITY, MODULE ROLE, USER ROLE
  • Data types – String, Integer, Boolean, DateTime, Decimal, etc.
  • Snippets – Multi-line templates for entity, microflow, page creation
  • Project references – Module names, entity names, microflow names (requires project)

Diagnostics Details

Parse Diagnostics (on change)

  • Syntax errors from the ANTLR4 lexer and parser
  • Missing tokens, unexpected tokens, mismatched brackets
  • Reported with exact line/column positions

Language Version Diagnostics (on change)

The server reads each document’s mdl <n>; header and reports what mxcli check reports for that version, at the token or line it is about:

  • MDL-DEPRnnn – a deprecated spelling (a warning; refused from the version its entry names)
  • MDL-V1-* – in a script without the header, a construct whose meaning changes under mdl 1;. Under the header it means the new thing and does not warn; a construct mdl 1 refuses is a parse error there.
  • MDL-LANG01 – the header names a version that is still a preview

Hovering over an MDL-DEPR* or MDL-V1-* warning shows mxcli help <code>: the old and new form, and whether fmt --upgrade rewrites it. The quick fix (Ctrl+.) is that rewrite:

  • a deprecated spelling, or a change whose rewrite means the same under every version, is rewritten where it stands;
  • any other MDL-V1-* change is fixed by upgrading the whole script, as mxcli fmt --upgrade --header does: its rewrite keeps the old meaning only once the header is there. It is offered only when that upgrade succeeds.

A use with no mechanical rewrite gets no quick fix – it is reported, never guessed at. With several deprecated spellings in a file, “Rewrite all … (fmt –upgrade)” fixes them at once. The migration codes are listed in Language versions and migration.

Semantic Diagnostics (on save)

  • Unresolved entity, microflow, or page references
  • Invalid attribute names on known entities
  • Module existence checks
  • Requires a loaded Mendix project

Not Yet Supported

The following LSP capabilities are not currently implemented:

LSP MethodStatus
textDocument/referencesPlanned
textDocument/renamePlanned
textDocument/formattingPlanned
textDocument/signatureHelpNot planned
workspace/symbolNot planned

Integration with Other Editors

The MDL language server works with any editor that supports the Language Server Protocol. This page covers setup for editors other than VS Code.

General Setup

All editors need to:

  1. Start mxcli lsp --stdio as a subprocess
  2. Communicate via JSON-RPC over stdin/stdout
  3. Associate the language server with .mdl files

Neovim

Using nvim-lspconfig

Add the following to your Neovim configuration:

local lspconfig = require('lspconfig')
local configs = require('lspconfig.configs')

-- Define the MDL language server
if not configs.mdl then
  configs.mdl = {
    default_config = {
      cmd = { 'mxcli', 'lsp', '--stdio' },
      filetypes = { 'mdl' },
      root_dir = function(fname)
        return lspconfig.util.find_git_ancestor(fname)
          or lspconfig.util.root_pattern('*.mpr')(fname)
      end,
      settings = {},
    },
  }
end

lspconfig.mdl.setup({})

File Type Detection

Add MDL file type recognition:

vim.filetype.add({
  extension = {
    mdl = 'mdl',
  },
})

Syntax Highlighting

For basic syntax highlighting without a full TreeSitter grammar, create ~/.config/nvim/syntax/mdl.vim with keyword highlights for MDL reserved words, or use the TextMate grammar from the VS Code extension via a plugin like nvim-textmate.

Emacs

Using lsp-mode

(require 'lsp-mode)

(add-to-list 'lsp-language-id-configuration '(mdl-mode . "mdl"))

(lsp-register-client
 (make-lsp-client
  :new-connection (lsp-stdio-connection '("mxcli" "lsp" "--stdio"))
  :activation-fn (lsp-activate-on "mdl")
  :server-id 'mdl-ls))

(add-hook 'mdl-mode-hook #'lsp)

Using eglot (Built-in since Emacs 29)

(add-to-list 'eglot-server-programs
             '(mdl-mode . ("mxcli" "lsp" "--stdio")))

(add-hook 'mdl-mode-hook #'eglot-ensure)

File Mode

Define a basic major mode for .mdl files:

(define-derived-mode mdl-mode prog-mode "MDL"
  "Major mode for editing MDL (Mendix Definition Language) files."
  (setq-local comment-start "-- ")
  (setq-local comment-end ""))

(add-to-list 'auto-mode-alist '("\\.mdl\\'" . mdl-mode))

Sublime Text

Using LSP Package

Install the LSP package, then add to your LSP settings:

{
  "clients": {
    "mdl": {
      "enabled": true,
      "command": ["mxcli", "lsp", "--stdio"],
      "selector": "source.mdl",
      "schemes": ["file"]
    }
  }
}

Helix

Add to ~/.config/helix/languages.toml:

[[language]]
name = "mdl"
scope = "source.mdl"
file-types = ["mdl"]
language-servers = ["mdl-ls"]
comment-token = "--"

[language-server.mdl-ls]
command = "mxcli"
args = ["lsp", "--stdio"]

Generic LSP Client

For any editor with LSP support, the key configuration values are:

ParameterValue
Commandmxcli lsp --stdio
Transportstdio
Language IDmdl
File extensions.mdl
Trigger characters.,

mxcli init & mxcli new

mxcli new — Create a project from scratch

If you don’t have a Mendix project yet, mxcli new creates one with everything configured in a single command:

mxcli new MyApp --version 11.8.0
mxcli new MyApp --version 10.24.0 --output-dir ./projects/my-app
mxcli new MyApp --version 11.8.0 --skip-init   # Skip AI tooling setup

This downloads MxBuild, creates a blank Mendix project via mx create-project, runs mxcli init to set up AI tooling and Dev Container, and downloads the correct Linux mxcli binary for the container. The result is a ready-to-open folder.

Placing that Linux binary is best-effort. If the download fails (no network, a proxy, or a development build with no matching release), mxcli new prints a warning and still exits 0, because the project is already complete. Add the binary afterwards with mxcli setup mxcli --output ./mxcli in the project directory, adding --tag <release> when you’re running a development build.

FlagDescription
--versionMendix version (required, e.g., 11.8.0)
--output-dirOutput directory (default: ./<app-name>)
--skip-initSkip AI tooling initialization

mxcli init — Add tooling to an existing project

The mxcli init command prepares an existing Mendix project for AI-assisted development. It creates configuration files, installs skills, sets up a dev container, and optionally installs the VS Code extension.

Basic Usage

# Initialize with Claude Code (default)
mxcli init /path/to/my-mendix-project

# Initialize for a specific AI tool
mxcli init --tool cursor /path/to/my-mendix-project

# Initialize for multiple tools
mxcli init --tool claude --tool cursor /path/to/my-mendix-project

# Initialize for all supported tools
mxcli init --all-tools /path/to/my-mendix-project

# List supported tools
mxcli init --list-tools

Supported AI Tools

ToolFlagConfig Files Created
Claude Code (default)--tool claude.claude/settings.json, CLAUDE.md, commands, lint rules
OpenCode--tool opencode.opencode/, opencode.json, commands, skills, lint rules
Cursor--tool cursor.cursorrules
Continue.dev--tool continue.continue/config.json
Windsurf--tool windsurf.windsurfrules
Aider--tool aider.aider.conf.yml
Mistral Vibe--tool vibe.vibe/config.toml, .vibe/prompts/, .vibe/skills/
GitHub Copilot--tool copilot.github/copilot-instructions.md

All tools also receive the universal files (AGENTS.md, .ai-context/).

What Happens During Init

  1. Detect the Mendix project – finds the .mpr file in the target directory
  2. Create universal AI context – AGENTS.md and .ai-context/skills/ with MDL pattern guides
  3. Create tool-specific configuration – based on the selected --tool flag(s)
  4. Set up dev container – .devcontainer/ with Dockerfile and configuration
  5. Copy mxcli binary – places the mxcli executable in the project root
  6. Install VS Code extension – copies and installs the bundled .vsix file
  7. Create mdlsource/ – the directory the generated CLAUDE.md tells the assistant to keep MDL scripts in, with a short README (mxcli new creates it even with --skip-init)
  8. Stamp the version – .ai-context/mxcli-tooling.json records which mxcli wrote the tooling; see Syncing with Updates

CLAUDE.md and AGENTS.md are written between <!-- mxcli:begin … --> and <!-- mxcli:end --> markers. Notes you add outside the markers survive a re-run of mxcli init and every mxcli init --sync-skills.

Adding a Tool Later

To add support for an additional AI tool to an existing project:

mxcli add-tool cursor

This creates only the tool-specific files without overwriting existing configuration.

What Gets Created

Running mxcli init (or mxcli new, which runs init automatically) creates the following directory structure in your Mendix project.

Universal Files (All Tools)

These files are shared by all AI tools:

your-mendix-project/
├── AGENTS.md                          # Comprehensive AI assistant guide (mxcli section between markers)
├── mdlsource/                         # MDL scripts, one file per concern
│   └── README.md
├── .ai-context/
│   ├── mxcli-tooling.json             # Which mxcli wrote this tooling (version stamp)
│   ├── skills/                        # MDL pattern guides
│   │   ├── write-microflows.md        # Microflow syntax and patterns
│   │   ├── create-page.md            # Page/widget syntax reference
│   │   ├── alter-page.md             # ALTER PAGE in-place modifications
│   │   ├── overview-pages.md         # CRUD page patterns
│   │   ├── master-detail-pages.md    # Master-detail page patterns
│   │   ├── generate-domain-model.md  # Entity/Association syntax
│   │   ├── check-syntax.md           # Pre-flight validation checklist
│   │   ├── organize-project.md       # Folders, MOVE, project structure
│   │   ├── manage-security.md        # Roles, access control, GRANT/REVOKE
│   │   ├── manage-navigation.md      # Navigation profiles, menus
│   │   ├── demo-data.md              # Database/import/demo data
│   │   ├── xpath-constraints.md      # XPath syntax in WHERE clauses
│   │   ├── database-connections.md   # External database connections
│   │   ├── test-microflows.md        # Test annotations and Docker setup
│   │   └── patterns-data-processing.md # Delta merge, batch processing
│   └── examples/                      # Example MDL scripts
├── mxcli                              # CLI executable (copied)
└── .devcontainer/                     # Dev container configuration
    ├── devcontainer.json
    └── Dockerfile

Tool-Specific Files

Claude Code

.claude/
├── settings.json          # Claude Code project settings
├── commands/              # Slash commands for Claude
│   └── mendix/           # Mendix-specific commands
├── lint-rules/            # Starlark lint rules
└── lint-config.yaml       # Lint settings (excluded modules, rule overrides)
CLAUDE.md                  # Project context for Claude

lint-config.yaml

Seeded with the System module excluded. System is Mendix’s own platform module — you cannot document its entities, give them access rules, or rename their members — so linting it produces findings you can never action. On a blank app that was the large majority of all issues.

mxcli init writes this file only when the project has no lint config yet (.claude/lint-config.yaml, lint-config.yaml, or .lint-config.yaml), so re-running init never discards your edits.

Add Marketplace modules (Atlas_Core, Administration, …) to excludeModules if their findings distract you — they are equally read-only in practice.

To lint System after all, remove it from excludeModules. Note the list always wins: it merges with --exclude, and a module listed there stays excluded even if you name it with --modules (lint warns when you try).

Cursor

.cursorrules               # Compact MDL reference and command guide

Continue.dev

.continue/
└── config.json            # Custom commands and slash commands

Windsurf

.windsurfrules             # MDL rules for Codeium's AI

Aider

.aider.conf.yml            # YAML configuration for Aider

Dev Container Contents

The .devcontainer/ provides a sandboxed environment with:

ComponentPurpose
mxcliMendix CLI binary
MxBuild / mxProject validation and building (auto-downloaded on first use)
JDK 21 (Adoptium)Required by MxBuild
Docker-in-DockerRunning Mendix apps locally with mxcli docker
Node.jsPlaywright testing support
PostgreSQL clientDatabase connectivity
Claude CodeAI coding assistant (auto-installed on container creation)

Key paths inside the container:

~/.mxcli/mxbuild/{version}/modeler/mx    # mx check / mx build
~/.mxcli/runtime/{version}/               # Mendix runtime (auto-downloaded)
./mxcli                                    # Project-local mxcli binary

Skills Overview

The skill files in .ai-context/skills/ teach AI assistants how to generate correct MDL. Each skill covers a specific topic:

Skill FileWhat It Teaches
write-microflows.mdMicroflow syntax, common mistakes, validation checklist
create-page.mdPage/widget syntax reference
overview-pages.mdCRUD page patterns (data grids, search, buttons)
generate-domain-model.mdEntity, attribute, association, enumeration syntax
manage-security.mdModule roles, user roles, GRANT/REVOKE patterns
demo-data.mdMendix ID system, association storage, data insertion
check-syntax.mdPre-flight validation checklist before execution

AI assistants are instructed to read the relevant skill file before generating MDL for that topic.

Customizing Skills

The skill files generated by mxcli init are starting points. You can modify them to match your organization’s patterns, naming conventions, and architectural standards.

Skill File Location

Skills are stored in .ai-context/skills/ (universal, shared by all tools) and optionally in .claude/skills/ (Claude Code-specific).

Modifying Existing Skills

Edit any skill file to adjust the patterns the AI follows. Common customizations include:

Naming Conventions

Change the default naming patterns in generate-domain-model.md:

-- Default pattern
CREATE PERSISTENT ENTITY Module.Customer ( ... );

-- Your convention: prefix entities with 'tbl_'
CREATE PERSISTENT ENTITY Module.tbl_Customer ( ... );

Page Layout Standards

Modify create-page.md or overview-pages.md to use your organization’s standard layouts:

-- Default
CREATE PAGE Module.CustomerOverview (
  Layout: Atlas_Core.Atlas_Default
) { ... }

-- Your standard layout
CREATE PAGE Module.CustomerOverview (
  Layout: MyTheme.MainLayout
) { ... }

Security Patterns

Update manage-security.md with your standard role hierarchy:

mdl 1;
-- Your organization's standard roles
CREATE MODULE ROLE Module.SystemAdmin DESCRIPTION 'Full system access';
CREATE MODULE ROLE Module.PowerUser DESCRIPTION 'Extended user access';
CREATE MODULE ROLE Module.StandardUser DESCRIPTION 'Normal user access';
CREATE MODULE ROLE Module.ReadOnly DESCRIPTION 'View-only access';

Adding New Skills

Create new .md files in .ai-context/skills/ for custom patterns:

.ai-context/skills/
├── write-microflows.md          # Built-in
├── create-page.md               # Built-in
├── my-api-integration.md        # Custom: your REST API patterns
├── my-approval-workflow.md      # Custom: approval workflow template
└── my-reporting-pattern.md      # Custom: standard report pages

Skill File Structure

A good skill file includes:

  1. Title and purpose – what the skill covers
  2. Syntax reference – the MDL syntax for the topic
  3. Complete examples – working MDL that can be adapted
  4. Common mistakes – pitfalls to avoid
  5. Validation checklist – steps to verify correctness

Example custom skill:

# REST API Integration Pattern

## When to Use
When creating microflows that call external REST APIs.

## Pattern

\```sql
CREATE MICROFLOW Module.Call_ExternalAPI($param: String)
RETURNS String AS $response
FOLDER 'Integration'
BEGIN
    -- Your standard REST call pattern here
    ...
END;
\```

## Common Mistakes
- Forgetting error handling for HTTP failures
- Not logging the request/response for debugging

Claude Code Commands

For Claude Code specifically, you can also add slash commands in .claude/commands/mendix/:

.claude/commands/mendix/
├── create-entity.md
├── create-overview.md
└── your-custom-command.md

These appear as /mendix:your-custom-command in the Claude Code interface.

Syncing with Updates

When you upgrade mxcli, the skills, lint rules and project guidance it ships may have changed. A project keeps them in step with the binary through one command, which the Claude Code bootstrap script runs on every session start:

mxcli init --sync-skills      # or: mxcli init --sync

It is quiet when everything is already current.

What Gets Refreshed

ComponentRefreshed by --sync-skillsWhat is never touched
Skills.ai-context/skills/ and .claude/skills/skill directories mxcli does not ship
Lint rulesthe bundled rules in .claude/lint-rules/, while each file is a version mxcli shippeda bundled rule you edited; any rule file under another name — your own rules
CLAUDE.md, AGENTS.mdthe part between the <!-- mxcli:begin … --> and <!-- mxcli:end --> markerseverything outside the markers; a file that does not exist is not created
Version stamp.ai-context/mxcli-tooling.json—

A bundled lint rule is refreshed only while its file is a version mxcli itself shipped. Once you edit it, it is yours: --sync-skills and mxcli init keep it and say so on the first sync by a new mxcli, and it no longer receives mxcli’s updates to the rule. Delete the file to take the current version back.

Moving an edited rule to a new file name works too. A bundled rule is not written while another file in .claude/lint-rules/ defines its rule ID, so the stock copy does not come back beside yours and report the same rule twice.

Prefer the options and overrides in .claude/lint-config.yaml where a rule has them (severity, options, or enabled: false to switch it off): those keep receiving the rule’s fixes.

The mxcli section of CLAUDE.md and AGENTS.md

mxcli init writes its content between two markers. Put project notes outside them — above or below — and every later sync and every re-run of mxcli init keeps them:

<!-- mxcli:begin — generated by mxcli and refreshed by 'mxcli init --sync-skills'. Edit outside these markers; anything between them is replaced. -->
# Mendix Project: MyApp
…
<!-- mxcli:end -->

## Our conventions

Invoices are immutable once sent.

A file written by an mxcli from before the markers cannot be split into mxcli’s text and yours, so the sync leaves it alone and prints a note. Run mxcli init once to adopt the markers: it regenerates the file, so move any notes you had added below the end marker afterwards.

The Version Stamp

mxcli init and every sync record the mxcli that wrote the tooling in .ai-context/mxcli-tooling.json:

{
  "version": "v0.25.0",
  "built": "2026-10-01T09:12:44Z",
  "written": "2026-10-03"
}

It is rewritten only when the version changes, so the daily session-start sync does not dirty the working tree. Commit it with the rest of the tooling.

An older binary warns. Any command that opens the project (-p) with a binary older than the stamp prints, once and on stderr, both versions and how to update. The tooling may use statements, commands or lint-rule fields the older binary does not have — mdl 1; is a parse error before it existed, and a rule reading a newer catalog field fails.

An older binary does not sync. init --sync-skills from an older binary refuses rather than rolling the skills, rules and CLAUDE.md back to what it knows. Update the binary instead. An explicit mxcli init from an older binary still runs, after the same warning.

Versions are compared only when the order is certain: two releases by number (v0.24.0 < v0.25.0), two nightlies by the date in the tag (nightly-20261002-…), a release against a nightly by build date. A dev build (v0.24.0-888-g4ba1495f2, -dirty, or no version) is never reported as older.

Binaries that predate the stamp

A binary can only check a stamp it knows to read. mxcli v0.24.0 and earlier open a project stamped by a newer mxcli without any warning. For them the guard is the bootstrap script, .claude/bootstrap-mxcli.sh, which a newer mxcli init regenerates: before it links the mxcli on PATH into the project it compares that binary’s version with the stamp, and it does not link an older one — it downloads MXCLI_TAG instead. MXCLI_TAG defaults to the release of the mxcli that generated the script (vX.Y.Z), or nightly when that was a nightly or development build. A ./mxcli older than the stamp is replaced the same way, through a temporary file, so a ./mxcli that is a symlink to the PATH binary never has the download written through it. If the download fails, the older ./mxcli is kept with a warning rather than leaving the project without a binary.

On Windows (Git Bash, MSYS2 or Cygwin) the script fetches the mxcli-windows-<arch>.exe asset and installs it, or links the PATH binary, as ./mxcli.exe. Git Bash, PowerShell and cmd can all run it as ./mxcli. A non-executable ./mxcli beside it, such as the Linux build that mxcli new leaves on Windows, is removed.

Re-running mxcli init

mxcli init regenerates everything, including the devcontainer and tool configuration files, and keeps project notes outside the CLAUDE.md / AGENTS.md markers. Use it after an upgrade that changed tool configuration; for skills, rules and guidance alone, --sync-skills is enough.

Build-Time Sync (for mxcli developers)

When building mxcli from source, make build automatically syncs all assets:

make build          # Syncs everything
make sync-skills    # Skills only
make sync-commands  # Commands only
make sync-vsix      # VS Code extension only

Checking Versions

mxcli version                          # the binary
cat .ai-context/mxcli-tooling.json     # the mxcli that wrote the project's tooling

Quick Start

The modelsdk-go library provides programmatic access to Mendix projects from Go code. It is the underlying library that powers the mxcli CLI tool.

Overview

  • Read Mendix project files (.mpr) to inspect modules, entities, microflows, pages, and more
  • Write changes back to projects: create entities, attributes, associations, microflows, pages
  • No cloud connectivity required – works directly with local .mpr files
  • No CGO required – uses a pure Go SQLite driver (modernc.org/sqlite)
  • Automatic format detection – handles both MPR v1 (single file) and v2 (directory-based) formats

Minimal Example

package main

import (
    "fmt"
    "github.com/mendixlabs/mxcli"
)

func main() {
    reader, err := modelsdk.Open("/path/to/MyApp.mpr")
    if err != nil {
        panic(err)
    }
    defer reader.Close()

    modules, _ := reader.ListModules()
    for _, m := range modules {
        fmt.Printf("Module: %s\n", m.Name)
    }
}

Two Access Modes

ModeFunctionUse Case
Read-onlymodelsdk.Open(path)Inspecting project structure, generating reports
Read-writemodelsdk.OpenForWriting(path)Creating entities, microflows, pages

Two API Levels

LevelPackageStyle
Low-level SDKmodelsdk (root)Direct type construction and writer calls
High-level Fluent APIapi/Builder pattern with method chaining

The low-level SDK gives full control over model elements. The fluent API provides a simplified interface for common operations.

MPR File Format

Mendix projects are stored in .mpr files which are SQLite databases containing BSON-encoded model elements.

  • MPR v1 (Mendix < 10.18): Single .mpr file containing all model data
  • MPR v2 (Mendix >= 10.18): .mpr metadata file + mprcontents/ folder with individual documents

The library automatically detects and handles both formats.

Model Structure

Project
├── Modules
│   ├── Domain Model
│   │   ├── Entities
│   │   │   ├── Attributes
│   │   │   ├── Indexes
│   │   │   ├── Access Rules
│   │   │   ├── Validation Rules
│   │   │   └── Event Handlers
│   │   ├── Associations
│   │   └── Annotations
│   ├── Microflows
│   │   ├── Parameters
│   │   └── Activities & Flows
│   ├── Nanoflows
│   ├── Pages
│   │   ├── Widgets
│   │   └── Data Sources
│   ├── Layouts
│   ├── Snippets
│   ├── Enumerations
│   ├── Constants
│   ├── Scheduled Events
│   └── Java Actions
└── Project Documents

Comparison with Official SDK

FeatureMendix Model SDK (TypeScript)modelsdk-go
LanguageTypeScript/JavaScriptGo
RuntimeNode.jsNative binary
Cloud RequiredYes (Platform API)No
Local FilesNoYes
Real-time CollaborationYesNo
Read OperationsYesYes
Write OperationsYesYes
Type SafetyYes (TypeScript)Yes (Go)
CLI ToolNoYes (mxcli)
SQL-like DSLNoYes (MDL)

Installation

Go Module

Install the library with go get:

go get github.com/mendixlabs/mxcli

Requirements

  • Go 1.21+
  • No CGO required – the project uses modernc.org/sqlite (pure Go SQLite) and does not require a C compiler

Dependencies

The library pulls in the following key dependencies:

DependencyPurpose
modernc.org/sqlitePure Go SQLite driver for reading .mpr files
go.mongodb.org/mongo-driverBSON parsing for Mendix document format

These are resolved automatically by go get.

Verify Installation

Create a simple Go program to verify the library is working:

package main

import (
    "fmt"
    "github.com/mendixlabs/mxcli"
)

func main() {
    reader, err := modelsdk.Open("/path/to/MyApp.mpr")
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }
    defer reader.Close()

    version := reader.GetMendixVersion()
    fmt.Printf("Mendix version: %s\n", version)
    fmt.Printf("MPR format: v%d\n", reader.Version())
}

Run it:

go run main.go

Building from Source

If you want to build the mxcli CLI tool from source:

git clone https://github.com/mendixlabs/mxcli.git
cd mxcli
make build

The binary will be placed in bin/mxcli.

Reading a Project

Open a Mendix project file (.mpr) in read-only mode to inspect its structure and contents.

Opening a Project

reader, err := modelsdk.Open("/path/to/MyApp.mpr")
if err != nil {
    panic(err)
}
defer reader.Close()

The library automatically detects the MPR format version (v1 or v2) and handles both transparently.

Complete Example

package main

import (
    "fmt"
    "github.com/mendixlabs/mxcli"
)

func main() {
    // Open a Mendix project
    reader, err := modelsdk.Open("/path/to/MyApp.mpr")
    if err != nil {
        panic(err)
    }
    defer reader.Close()

    // List all modules
    modules, _ := reader.ListModules()
    for _, m := range modules {
        fmt.Printf("Module: %s\n", m.Name)
    }

    // Get domain model for a module
    dm, _ := reader.GetDomainModel(modules[0].ID)
    for _, entity := range dm.Entities {
        fmt.Printf("  Entity: %s\n", entity.Name)
        for _, attr := range entity.Attributes {
            fmt.Printf("    - %s: %s\n", attr.Name, attr.Type.GetTypeName())
        }
    }

    // List microflows
    microflows, _ := reader.ListMicroflows()
    fmt.Printf("Total microflows: %d\n", len(microflows))

    // List pages
    pages, _ := reader.ListPages()
    fmt.Printf("Total pages: %d\n", len(pages))
}

Reader Methods

Metadata

reader.Path()                    // Get file path
reader.Version()                 // Get MPR version (1 or 2)
reader.GetMendixVersion()        // Get Mendix Studio Pro version

Modules

reader.ListModules()             // List all modules
reader.GetModule(id)             // Get module by ID
reader.GetModuleByName(name)     // Get module by name

Domain Models

reader.ListDomainModels()        // List all domain models
reader.GetDomainModel(moduleID)  // Get domain model for module

Microflows and Nanoflows

reader.ListMicroflows()          // List all microflows
reader.GetMicroflow(id)          // Get microflow by ID
reader.ListNanoflows()           // List all nanoflows
reader.GetNanoflow(id)           // Get nanoflow by ID

Pages and Layouts

reader.ListPages()               // List all pages
reader.GetPage(id)               // Get page by ID
reader.ListLayouts()             // List all layouts
reader.GetLayout(id)             // Get layout by ID

Other Elements

reader.ListEnumerations()        // List all enumerations
reader.ListConstants()           // List all constants
reader.ListScheduledEvents()     // List all scheduled events
reader.ExportJSON()              // Export entire model as JSON

Running the Example

cd examples/read_project
go run main.go /path/to/MyApp.mpr

Data Flow

sequenceDiagram
    participant App
    participant SDK as modelsdk
    participant Reader
    participant Parser
    participant FS as File System

    App->>SDK: Open("project.mpr")
    SDK->>Reader: NewReader(path)
    Reader->>FS: Detect MPR version
    alt MPR v1
        Reader->>FS: Open SQLite database
    else MPR v2
        Reader->>FS: Read mprcontents/
    end
    SDK-->>App: Reader

    App->>SDK: ListDomainModels()
    SDK->>Reader: listUnitsByType("DomainModels$DomainModel")
    Reader->>FS: Read unit contents
    Reader->>Parser: parseDomainModel(bson)
    Parser-->>Reader: *DomainModel
    Reader-->>SDK: []*DomainModel
    SDK-->>App: []*DomainModel

Units are loaded on-demand for performance. The reader uses lazy loading so that related documents are only parsed when accessed.

Modifying a Project

Make changes to a Mendix project file using the writer API. Always back up your .mpr file before modifying it.

Opening for Writing

writer, err := modelsdk.OpenForWriting("/path/to/MyApp.mpr")
if err != nil {
    panic(err)
}
defer writer.Close()

The writer provides access to the underlying reader via writer.Reader().

Complete Example

package main

import (
    "github.com/mendixlabs/mxcli"
)

func main() {
    // Open for writing
    writer, err := modelsdk.OpenForWriting("/path/to/MyApp.mpr")
    if err != nil {
        panic(err)
    }
    defer writer.Close()

    reader := writer.Reader()
    modules, _ := reader.ListModules()
    dm, _ := reader.GetDomainModel(modules[0].ID)

    // Create a new entity
    customer := modelsdk.NewEntity("Customer")
    writer.CreateEntity(dm.ID, customer)

    // Add attributes
    writer.AddAttribute(dm.ID, customer.ID, modelsdk.NewStringAttribute("Name", 200))
    writer.AddAttribute(dm.ID, customer.ID, modelsdk.NewStringAttribute("Email", 254))
    writer.AddAttribute(dm.ID, customer.ID, modelsdk.NewBooleanAttribute("IsActive"))
    writer.AddAttribute(dm.ID, customer.ID, modelsdk.NewDateTimeAttribute("CreatedDate", true))

    // Create another entity
    order := modelsdk.NewEntity("Order")
    writer.CreateEntity(dm.ID, order)

    // Create an association
    assoc := modelsdk.NewAssociation("Customer_Order", customer.ID, order.ID)
    writer.CreateAssociation(dm.ID, assoc)
}

Writer Methods

Modules

writer.CreateModule(module)
writer.UpdateModule(module)
writer.DeleteModule(id)

Entities

writer.CreateEntity(domainModelID, entity)
writer.UpdateEntity(domainModelID, entity)
writer.DeleteEntity(domainModelID, entityID)

Attributes

writer.AddAttribute(domainModelID, entityID, attribute)

Associations

writer.CreateAssociation(domainModelID, association)
writer.DeleteAssociation(domainModelID, associationID)

Microflows and Nanoflows

writer.CreateMicroflow(microflow)
writer.UpdateMicroflow(microflow)
writer.DeleteMicroflow(id)
writer.CreateNanoflow(nanoflow)
writer.UpdateNanoflow(nanoflow)
writer.DeleteNanoflow(id)

Pages and Layouts

writer.CreatePage(page)
writer.UpdatePage(page)
writer.DeletePage(id)
writer.CreateLayout(layout)
writer.UpdateLayout(layout)
writer.DeleteLayout(id)

Other

writer.CreateEnumeration(enumeration)
writer.CreateConstant(constant)

Helper Functions

// Create attributes
modelsdk.NewStringAttribute(name, length)
modelsdk.NewIntegerAttribute(name)
modelsdk.NewDecimalAttribute(name)
modelsdk.NewBooleanAttribute(name)
modelsdk.NewDateTimeAttribute(name, localize)
modelsdk.NewEnumerationAttribute(name, enumID)

// Create entities
modelsdk.NewEntity(name)                 // Persistable entity
modelsdk.NewNonPersistableEntity(name)   // Non-persistable entity

// Create associations
modelsdk.NewAssociation(name, parentID, childID)      // Reference (1:N)
modelsdk.NewReferenceSetAssociation(name, p, c)       // Reference set (M:N)

// Create flows
modelsdk.NewMicroflow(name)
modelsdk.NewNanoflow(name)

// Create pages
modelsdk.NewPage(name)

// Generate IDs
modelsdk.GenerateID()

Data Flow

sequenceDiagram
    participant App
    participant SDK as modelsdk
    participant Writer
    participant FS as File System

    App->>SDK: OpenForWriting("project.mpr")
    SDK->>Writer: NewWriter(path)
    Writer->>FS: Load existing units
    SDK-->>App: Writer

    App->>SDK: NewEntity()
    SDK-->>App: *Entity

    App->>SDK: AddEntity(dm, entity)
    SDK->>Writer: SerializeEntity(entity)
    Writer->>Writer: Generate BSON

    App->>SDK: Save()
    SDK->>Writer: Write all modified units
    Writer->>FS: Write .mxunit files
    Writer->>FS: Update .mpr metadata

Running the Example

cd examples/modify_project
go run main.go /path/to/MyApp.mpr

Warning: Always backup your .mpr file before modifying it.

Public API

The public API surface is defined in modelsdk.go at the package root. It provides convenience functions for opening projects and constructing model elements.

Entry Points

// Read-only access
reader, err := modelsdk.Open("/path/to/project.mpr")
defer reader.Close()

// Read-write access
writer, err := modelsdk.OpenForWriting("/path/to/project.mpr")
defer writer.Close()

Core Types

TypeDescription
modelsdk.IDUnique identifier for model elements (UUID)
modelsdk.ModuleRepresents a Mendix module
modelsdk.ProjectRepresents a Mendix project
modelsdk.DomainModelContains entities and associations
modelsdk.EntityAn entity in the domain model
modelsdk.AttributeAn attribute of an entity
modelsdk.AssociationA relationship between entities
modelsdk.MicroflowA microflow (server-side logic)
modelsdk.NanoflowA nanoflow (client-side logic)
modelsdk.PageA page in the UI
modelsdk.LayoutA page layout template

Package Structure

github.com/mendixlabs/mxcli/
├── modelsdk.go          # Main package with convenience functions
├── model/               # Core model types (ID, Module, Project, etc.)
├── api/                 # High-level fluent API (builders)
│   ├── api.go           # ModelAPI entry point
│   ├── domainmodels.go  # EntityBuilder, AssociationBuilder
│   ├── enumerations.go  # EnumerationBuilder
│   ├── microflows.go    # MicroflowBuilder
│   ├── pages.go         # PageBuilder, widget builders
│   └── modules.go       # ModulesAPI
├── sdk/
│   ├── domainmodel/     # Domain model types (Entity, Attribute, Association)
│   ├── microflows/      # Microflow and Nanoflow types
│   ├── pages/           # Page, Layout, and Widget types
│   └── mpr/             # MPR file reader and writer
└── examples/            # Example applications

Reader API

The reader provides read-only access to all project elements:

reader, _ := modelsdk.Open("path/to/project.mpr")
defer reader.Close()

// Metadata
reader.Path()                    // Get file path
reader.Version()                 // Get MPR version (1 or 2)
reader.GetMendixVersion()        // Get Mendix Studio Pro version

// Modules
reader.ListModules()             // List all modules
reader.GetModule(id)             // Get module by ID
reader.GetModuleByName(name)     // Get module by name

// Domain Models
reader.ListDomainModels()        // List all domain models
reader.GetDomainModel(moduleID)  // Get domain model for module

// Microflows & Nanoflows
reader.ListMicroflows()          // List all microflows
reader.GetMicroflow(id)          // Get microflow by ID
reader.ListNanoflows()           // List all nanoflows
reader.GetNanoflow(id)           // Get nanoflow by ID

// Pages & Layouts
reader.ListPages()               // List all pages
reader.GetPage(id)               // Get page by ID
reader.ListLayouts()             // List all layouts
reader.GetLayout(id)             // Get layout by ID

// Other
reader.ListEnumerations()        // List all enumerations
reader.ListConstants()           // List all constants
reader.ListScheduledEvents()     // List all scheduled events
reader.ExportJSON()              // Export entire model as JSON

Writer API

The writer provides read-write access. Use writer.Reader() for read operations:

writer, _ := modelsdk.OpenForWriting("path/to/project.mpr")
defer writer.Close()

reader := writer.Reader()

// Module operations
writer.CreateModule(module)
writer.UpdateModule(module)
writer.DeleteModule(id)

// Entity operations
writer.CreateEntity(domainModelID, entity)
writer.UpdateEntity(domainModelID, entity)
writer.DeleteEntity(domainModelID, entityID)

// Attribute operations
writer.AddAttribute(domainModelID, entityID, attribute)

// Association operations
writer.CreateAssociation(domainModelID, association)
writer.DeleteAssociation(domainModelID, associationID)

// Microflow operations
writer.CreateMicroflow(microflow)
writer.UpdateMicroflow(microflow)
writer.DeleteMicroflow(id)

// Page operations
writer.CreatePage(page)
writer.UpdatePage(page)
writer.DeletePage(id)

// Other
writer.CreateEnumeration(enumeration)
writer.CreateConstant(constant)

Constructor Functions

Helper functions for creating model elements with proper defaults:

// Attributes
modelsdk.NewStringAttribute(name, length)
modelsdk.NewIntegerAttribute(name)
modelsdk.NewDecimalAttribute(name)
modelsdk.NewBooleanAttribute(name)
modelsdk.NewDateTimeAttribute(name, localize)
modelsdk.NewEnumerationAttribute(name, enumID)

// Entities
modelsdk.NewEntity(name)                 // Persistable
modelsdk.NewNonPersistableEntity(name)   // Non-persistable

// Associations
modelsdk.NewAssociation(name, parentID, childID)      // 1:N
modelsdk.NewReferenceSetAssociation(name, p, c)       // M:N

// Flows
modelsdk.NewMicroflow(name)
modelsdk.NewNanoflow(name)

// Pages
modelsdk.NewPage(name)

// IDs
modelsdk.GenerateID()

Open / OpenForWriting

The two entry points for accessing a Mendix project from Go code.

Read-Only Access

Use modelsdk.Open() when you only need to inspect the project:

import modelsdk "github.com/mendixlabs/mxcli"

reader, err := modelsdk.Open("/path/to/MyApp.mpr")
if err != nil {
    log.Fatal(err)
}
defer reader.Close()

// Query the project
modules, _ := reader.ListModules()
dm, _ := reader.GetDomainModel(modules[0].ID)

The reader provides all query methods (listing modules, entities, microflows, pages, etc.) but cannot modify the project. The .mpr file is opened with a read-only SQLite connection.

Read-Write Access

Use modelsdk.OpenForWriting() when you need to create or modify elements:

import modelsdk "github.com/mendixlabs/mxcli"

writer, err := modelsdk.OpenForWriting("/path/to/MyApp.mpr")
if err != nil {
    log.Fatal(err)
}
defer writer.Close()

// Access read methods through the embedded reader
reader := writer.Reader()
modules, _ := reader.ListModules()

// Create new elements
entity := modelsdk.NewEntity("Customer")
writer.CreateEntity(domainModelID, entity)

The writer opens the .mpr file with a read-write SQLite connection. Always back up your .mpr file before modifying it – the library writes directly to the database.

Format Auto-Detection

Both functions automatically detect the MPR format version:

VersionMendixStorage
v1< 10.18Single .mpr SQLite file with BSON blobs
v2>= 10.18.mpr metadata + mprcontents/ folder with .mxunit files

No configuration is needed – the library inspects the file structure and selects the appropriate reader/writer implementation.

Error Handling

Both functions return an error if:

  • The file path does not exist
  • The file is not a valid SQLite database
  • The file is not a recognized MPR format
  • (For OpenForWriting) the file is read-only or locked by another process
reader, err := modelsdk.Open("/path/to/project.mpr")
if err != nil {
    // Handle: file not found, not a valid MPR, etc.
    log.Fatalf("Failed to open project: %v", err)
}

Pure Go – No CGO Required

The library uses modernc.org/sqlite, a pure Go SQLite implementation. No C compiler, CGO, or system SQLite library is needed. This simplifies cross-compilation and deployment.

Reader Methods

Complete list of methods available on the reader returned by modelsdk.Open().

Metadata

reader.Path()                    // string -- file path of the .mpr
reader.Version()                 // int -- MPR version (1 or 2)
reader.GetMendixVersion()        // string -- Mendix Studio Pro version (e.g., "11.6.0")

Modules

reader.ListModules()             // ([]*model.Module, error) -- all modules
reader.GetModule(id)             // (*model.Module, error) -- module by ID
reader.GetModuleByName(name)     // (*model.Module, error) -- module by name

Domain Models

reader.ListDomainModels()        // ([]*domainmodel.DomainModel, error) -- all domain models
reader.GetDomainModel(moduleID)  // (*domainmodel.DomainModel, error) -- domain model for a module

The DomainModel struct contains:

  • Entities – slice of *domainmodel.Entity, each with Attributes, Indexes, ValidationRules, AccessRules, and EventHandlers
  • Associations – slice of *domainmodel.Association

Microflows and Nanoflows

reader.ListMicroflows()          // ([]*microflows.Microflow, error) -- all microflows
reader.GetMicroflow(id)          // (*microflows.Microflow, error) -- microflow by ID
reader.ListNanoflows()           // ([]*microflows.Nanoflow, error) -- all nanoflows
reader.GetNanoflow(id)           // (*microflows.Nanoflow, error) -- nanoflow by ID

Pages and Layouts

reader.ListPages()               // ([]*pages.Page, error) -- all pages
reader.GetPage(id)               // (*pages.Page, error) -- page by ID
reader.ListLayouts()             // ([]*pages.Layout, error) -- all layouts
reader.GetLayout(id)             // (*pages.Layout, error) -- layout by ID

Enumerations

reader.ListEnumerations()        // ([]*model.Enumeration, error) -- all enumerations

Each enumeration contains:

  • Name – enumeration name
  • Values – slice of *model.EnumerationValue with Name and Caption

Other Documents

reader.ListConstants()           // ([]*model.Constant, error) -- all constants
reader.ListScheduledEvents()     // ([]*model.ScheduledEvent, error) -- all scheduled events

Export

reader.ExportJSON()              // ([]byte, error) -- entire model as JSON

Example: Full Project Scan

reader, _ := modelsdk.Open("/path/to/MyApp.mpr")
defer reader.Close()

fmt.Printf("Mendix version: %s\n", reader.GetMendixVersion())

modules, _ := reader.ListModules()
for _, m := range modules {
    fmt.Printf("Module: %s\n", m.Name)

    dm, _ := reader.GetDomainModel(m.ID)
    for _, entity := range dm.Entities {
        fmt.Printf("  Entity: %s (persistent: %v)\n", entity.Name, entity.Persistable)
        for _, attr := range entity.Attributes {
            fmt.Printf("    - %s: %s\n", attr.Name, attr.Type.GetTypeName())
        }
    }
}

microflows, _ := reader.ListMicroflows()
fmt.Printf("Total microflows: %d\n", len(microflows))

pages, _ := reader.ListPages()
fmt.Printf("Total pages: %d\n", len(pages))

Writer Methods

Complete list of methods available on the writer returned by modelsdk.OpenForWriting().

Accessing the Reader

The writer embeds a reader for querying the project:

writer, _ := modelsdk.OpenForWriting("/path/to/MyApp.mpr")
defer writer.Close()

reader := writer.Reader()
modules, _ := reader.ListModules()

Modules

writer.CreateModule(module)      // error -- create a new module
writer.UpdateModule(module)      // error -- update module metadata
writer.DeleteModule(id)          // error -- delete a module and its contents

Entities

writer.CreateEntity(domainModelID, entity)       // error -- add entity to domain model
writer.UpdateEntity(domainModelID, entity)       // error -- update an existing entity
writer.DeleteEntity(domainModelID, entityID)     // error -- remove entity from domain model

Attributes

writer.AddAttribute(domainModelID, entityID, attribute)  // error -- add attribute to entity

Associations

writer.CreateAssociation(domainModelID, association)     // error -- create association
writer.DeleteAssociation(domainModelID, associationID)   // error -- remove association

Microflows and Nanoflows

writer.CreateMicroflow(microflow)    // error -- create a new microflow
writer.UpdateMicroflow(microflow)    // error -- update an existing microflow
writer.DeleteMicroflow(id)           // error -- delete a microflow

writer.CreateNanoflow(nanoflow)      // error -- create a new nanoflow
writer.UpdateNanoflow(nanoflow)      // error -- update an existing nanoflow
writer.DeleteNanoflow(id)            // error -- delete a nanoflow

Pages and Layouts

writer.CreatePage(page)              // error -- create a new page
writer.UpdatePage(page)              // error -- update an existing page
writer.DeletePage(id)                // error -- delete a page

writer.CreateLayout(layout)          // error -- create a new layout
writer.UpdateLayout(layout)          // error -- update an existing layout
writer.DeleteLayout(id)              // error -- delete a layout

Enumerations and Constants

writer.CreateEnumeration(enumeration)    // error -- create a new enumeration
writer.CreateConstant(constant)          // error -- create a new constant

Helper Functions

The modelsdk package provides helper functions for creating common types:

// Entities
modelsdk.NewEntity(name)                          // *Entity -- persistent entity
modelsdk.NewNonPersistableEntity(name)            // *Entity -- non-persistent entity

// Attributes
modelsdk.NewStringAttribute(name, length)         // *Attribute
modelsdk.NewIntegerAttribute(name)                // *Attribute
modelsdk.NewDecimalAttribute(name)                // *Attribute
modelsdk.NewBooleanAttribute(name)                // *Attribute
modelsdk.NewDateTimeAttribute(name, localize)     // *Attribute
modelsdk.NewEnumerationAttribute(name, enumID)    // *Attribute
modelsdk.NewAutoNumberAttribute(name)             // *Attribute

// Associations
modelsdk.NewAssociation(name, parentID, childID)          // *Association -- Reference (1:N)
modelsdk.NewReferenceSetAssociation(name, parentID, childID)  // *Association -- ReferenceSet (M:N)

// Flows
modelsdk.NewMicroflow(name)     // *Microflow
modelsdk.NewNanoflow(name)      // *Nanoflow

// Pages
modelsdk.NewPage(name)          // *Page

// IDs
modelsdk.GenerateID()           // string -- new UUID v4

Example: Create an Entity with Attributes

writer, _ := modelsdk.OpenForWriting("/path/to/MyApp.mpr")
defer writer.Close()

reader := writer.Reader()
modules, _ := reader.ListModules()
dm, _ := reader.GetDomainModel(modules[0].ID)

// Create entity
customer := modelsdk.NewEntity("Customer")
customer.Documentation = "Customer master data"
customer.Location = model.Point{X: 100, Y: 200}
writer.CreateEntity(dm.ID, customer)

// Add attributes
writer.AddAttribute(dm.ID, customer.ID, modelsdk.NewStringAttribute("Name", 200))
writer.AddAttribute(dm.ID, customer.ID, modelsdk.NewStringAttribute("Email", 254))
writer.AddAttribute(dm.ID, customer.ID, modelsdk.NewBooleanAttribute("IsActive"))
writer.AddAttribute(dm.ID, customer.ID, modelsdk.NewDateTimeAttribute("CreatedDate", true))

// Create association
order := modelsdk.NewEntity("Order")
writer.CreateEntity(dm.ID, order)

assoc := modelsdk.NewAssociation("Customer_Order", customer.ID, order.ID)
writer.CreateAssociation(dm.ID, assoc)

Warning: Always back up your .mpr file before modifying it. The writer operates directly on the SQLite database.

Fluent API

The api/ package provides a high-level, fluent builder API inspired by the Mendix Web Extensibility Model API. It simplifies common operations with method chaining and sensible defaults.

Setup

package main

import (
    "github.com/mendixlabs/mxcli/api"
)

func main() {
    modelAPI, err := api.Open("/path/to/MyApp.mpr")
    if err != nil {
        panic(err)
    }
    defer modelAPI.Close()

    // Set the current module context
    module, _ := modelAPI.Modules.Get("MyModule")
    modelAPI.SetModule(module)
}

API Structure

classDiagram
    class ModelAPI {
        +writer *Writer
        +reader *Reader
        +currentModule *Module
        +DomainModels *DomainModelsAPI
        +Microflows *MicroflowsAPI
        +Pages *PagesAPI
        +Enumerations *EnumerationsAPI
        +Modules *ModulesAPI
        +New(writer) *ModelAPI
        +SetModule(module) *ModelAPI
    }

    class EntityBuilder {
        +Persistent() *EntityBuilder
        +NonPersistent() *EntityBuilder
        +WithStringAttribute(name, length) *EntityBuilder
        +WithIntegerAttribute(name) *EntityBuilder
        +Build() (*Entity, error)
    }

    class MicroflowBuilder {
        +WithParameter(name, entity) *MicroflowBuilder
        +WithStringParameter(name) *MicroflowBuilder
        +ReturnsBoolean() *MicroflowBuilder
        +Build() (*Microflow, error)
    }

    ModelAPI --> EntityBuilder : creates
    ModelAPI --> MicroflowBuilder : creates

Namespaces

NamespaceDescription
modelAPI.DomainModelsCreate/modify entities, attributes, associations
modelAPI.EnumerationsCreate/modify enumerations and values
modelAPI.MicroflowsCreate microflows with parameters and return types
modelAPI.PagesCreate pages with widgets (DataView, TextBox, etc.)
modelAPI.ModulesList and retrieve modules

Creating Entities

// Create entity with fluent builder
customer, err := modelAPI.DomainModels.CreateEntity("Customer").
    Persistent().
    WithStringAttribute("Name", 100).
    WithStringAttribute("Email", 254).
    WithIntegerAttribute("Age").
    WithBooleanAttribute("IsActive").
    WithDateTimeAttribute("CreatedDate").
    Build()

Creating Associations

_, err := modelAPI.DomainModels.CreateAssociation("Customer_Orders").
    From("Customer").
    To("Order").
    OneToMany().
    Build()

Creating Enumerations

_, err := modelAPI.Enumerations.CreateEnumeration("OrderStatus").
    WithValue("Pending", "Pending").
    WithValue("Processing", "Processing").
    WithValue("Completed", "Completed").
    WithValue("Cancelled", "Cancelled").
    Build()

Creating Microflows

_, err := modelAPI.Microflows.CreateMicroflow("ACT_ProcessOrder").
    WithParameter("Order", "MyModule.Order").
    WithStringParameter("Message").
    ReturnsBoolean().
    Build()

Complete Example

package main

import (
    "github.com/mendixlabs/mxcli/api"
)

func main() {
    modelAPI, err := api.Open("/path/to/MyApp.mpr")
    if err != nil {
        panic(err)
    }
    defer modelAPI.Close()


    module, _ := modelAPI.Modules.Get("MyModule")
    modelAPI.SetModule(module)

    // Create entity with fluent builder
    customer, _ := modelAPI.DomainModels.CreateEntity("Customer").
        Persistent().
        WithStringAttribute("Name", 100).
        WithStringAttribute("Email", 254).
        WithIntegerAttribute("Age").
        WithBooleanAttribute("IsActive").
        WithDateTimeAttribute("CreatedDate").
        Build()

    // Create another entity
    order, _ := modelAPI.DomainModels.CreateEntity("Order").
        Persistent().
        WithDecimalAttribute("TotalAmount").
        WithDateTimeAttribute("OrderDate").
        Build()

    // Create association between entities
    _, _ = modelAPI.DomainModels.CreateAssociation("Customer_Orders").
        From("Customer").
        To("Order").
        OneToMany().
        Build()

    // Create enumeration
    _, _ = modelAPI.Enumerations.CreateEnumeration("OrderStatus").
        WithValue("Pending", "Pending").
        WithValue("Processing", "Processing").
        WithValue("Completed", "Completed").
        WithValue("Cancelled", "Cancelled").
        Build()

    // Create microflow
    _, _ = modelAPI.Microflows.CreateMicroflow("ACT_ProcessOrder").
        WithParameter("Order", "MyModule.Order").
        WithStringParameter("Message").
        ReturnsBoolean().
        Build()
}

Package Files

FilePurpose
api/api.goModelAPI entry point with namespace access
api/domainmodels.goEntityBuilder, AssociationBuilder, AttributeBuilder
api/enumerations.goEnumerationBuilder, EnumValueBuilder
api/microflows.goMicroflowBuilder with parameters and return types
api/pages.goPageBuilder, widget builders
api/modules.goModulesAPI for module retrieval

ModelAPI Entry Point

The api package provides a high-level fluent API inspired by the Mendix Web Extensibility Model API. It wraps the lower-level reader/writer with builder patterns for common operations.

Creating the ModelAPI

import (
    "github.com/mendixlabs/mxcli/api"
)

modelAPI, err := api.Open("/path/to/MyApp.mpr")
if err != nil {
    panic(err)
}
defer modelAPI.Close()

Setting the Module Context

Most operations require a module context. Set it before calling builders:

module, _ := modelAPI.Modules.Get("MyModule")
modelAPI.SetModule(module)

All subsequent Create* calls will create elements in this module.

Namespace Accessors

The ModelAPI struct provides access to domain-specific APIs through namespace accessors:

NamespaceAccessorDescription
DomainModelsmodelAPI.DomainModelsCreate/modify entities, attributes, associations
EnumerationsmodelAPI.EnumerationsCreate/modify enumerations and their values
MicroflowsmodelAPI.MicroflowsCreate microflows with parameters and return types
PagesmodelAPI.PagesCreate pages with widgets
ModulesmodelAPI.ModulesList and retrieve modules

DomainModels Namespace

// Create an entity
entity, _ := modelAPI.DomainModels.CreateEntity("Customer").
    Persistent().
    WithStringAttribute("Name", 200).
    Build()

// Create an association
assoc, _ := modelAPI.DomainModels.CreateAssociation("Customer_Orders").
    From("Customer").
    To("Order").
    OneToMany().
    Build()

Enumerations Namespace

enum, _ := modelAPI.Enumerations.CreateEnumeration("OrderStatus").
    WithValue("Draft", "Draft").
    WithValue("Active", "Active").
    WithValue("Closed", "Closed").
    Build()

Microflows Namespace

mf, _ := modelAPI.Microflows.CreateMicroflow("ACT_ProcessOrder").
    WithParameter("Order", "MyModule.Order").
    WithStringParameter("Note").
    ReturnsBoolean().
    Build()

Pages Namespace

page, _ := modelAPI.Pages.CreatePage("CustomerOverview").
    WithTitle("Customer Overview").
    Build()

Modules Namespace

// List all modules
modules, _ := modelAPI.Modules.ListModules()

// Get a specific module
module, _ := modelAPI.Modules.Get("MyModule")

MDL to Fluent API Mapping

MDL StatementFluent API Method
CREATE PERSISTENT ENTITYDomainModels.CreateEntity().Persistent().Build()
CREATE NON-PERSISTENT ENTITYDomainModels.CreateEntity().NonPersistent().Build()
CREATE ASSOCIATIONDomainModels.CreateAssociation().Build()
CREATE ENUMERATIONEnumerations.CreateEnumeration().Build()
CREATE MICROFLOWMicroflows.CreateMicroflow().Build()
CREATE PAGEPages.CreatePage().Build()

When to Use the Fluent API vs Direct SDK

Use CaseRecommended Approach
Simple CRUD operationsFluent API (less boilerplate)
Complex microflow constructionDirect SDK types (more control)
Bulk operationsDirect writer methods (batch efficiency)
Integration testingFluent API (readable test code)

Builders

The api package provides fluent builder types for constructing Mendix model elements. Each builder follows the pattern: create, configure with chained methods, then call Build().

EntityBuilder

Creates entities with attributes, persistence settings, and documentation.

entity, err := modelAPI.DomainModels.CreateEntity("Customer").
    Persistent().
    WithStringAttribute("Name", 200).
    WithStringAttribute("Email", 254).
    WithIntegerAttribute("Age").
    WithBooleanAttribute("IsActive").
    WithDateTimeAttribute("CreatedDate").
    WithDecimalAttribute("Revenue").
    WithEnumerationAttribute("Status", "MyModule.CustomerStatus").
    Build()

EntityBuilder Methods

MethodDescription
Persistent()Mark as persistent (stored in database)
NonPersistent()Mark as non-persistent (in-memory only)
WithStringAttribute(name, length)Add a string attribute
WithIntegerAttribute(name)Add an integer attribute
WithLongAttribute(name)Add a long attribute
WithDecimalAttribute(name)Add a decimal attribute
WithBooleanAttribute(name)Add a boolean attribute
WithDateTimeAttribute(name)Add a datetime attribute
WithAutoNumberAttribute(name)Add an auto-number attribute
WithEnumerationAttribute(name, enumRef)Add an enumeration attribute
Build()Create the entity and write it to the project

AssociationBuilder

Creates associations (relationships) between entities.

assoc, err := modelAPI.DomainModels.CreateAssociation("Customer_Orders").
    From("Customer").
    To("Order").
    OneToMany().
    Build()

AssociationBuilder Methods

MethodDescription
From(entityName)Set the FROM entity (FK owner)
To(entityName)Set the TO entity (referenced)
OneToMany()Reference type with Default owner (1:N)
ManyToMany()ReferenceSet type (M:N)
Build()Create the association and write it to the project

EnumerationBuilder

Creates enumerations with named values and captions.

enum, err := modelAPI.Enumerations.CreateEnumeration("OrderStatus").
    WithValue("Draft", "Draft Order").
    WithValue("Pending", "Pending Approval").
    WithValue("Completed", "Completed").
    WithValue("Cancelled", "Cancelled").
    Build()

EnumerationBuilder Methods

MethodDescription
WithValue(name, caption)Add a value with its display caption
Build()Create the enumeration and write it to the project

MicroflowBuilder

Creates microflows with parameters and return types.

mf, err := modelAPI.Microflows.CreateMicroflow("ACT_ProcessOrder").
    WithParameter("Order", "Sales.Order").
    WithStringParameter("Message").
    ReturnsBoolean().
    Build()

MicroflowBuilder Methods

MethodDescription
WithParameter(name, entityType)Add an object parameter
WithStringParameter(name)Add a string parameter
ReturnsBoolean()Set return type to Boolean
ReturnsString()Set return type to String
ReturnsVoid()Set return type to Nothing (void)
Build()Create the microflow and write it to the project

PageBuilder

Creates pages with title and layout configuration.

page, err := modelAPI.Pages.CreatePage("CustomerOverview").
    WithTitle("Customer Overview").
    Build()

PageBuilder Methods

MethodDescription
WithTitle(title)Set the page title
Build()Create the page and write it to the project

Error Handling

All Build() methods return (result, error). Common errors include:

  • Module context not set (modelAPI.SetModule() not called)
  • Entity not found (for associations referencing nonexistent entities)
  • Duplicate names within the same module
  • Invalid attribute types or references
entity, err := modelAPI.DomainModels.CreateEntity("Customer").
    Persistent().
    Build()
if err != nil {
    log.Fatalf("Failed to create entity: %v", err)
}

Examples

Complete fluent API examples for common tasks.

Setup

All examples start with the same setup:

package main

import (
    "log"
    "github.com/mendixlabs/mxcli/api"
)

func main() {
    modelAPI, err := api.Open("/path/to/MyApp.mpr")
    if err != nil {
        log.Fatal(err)
    }
    defer modelAPI.Close()

    module, _ := modelAPI.Modules.Get("Sales")
    modelAPI.SetModule(module)

    // ... examples below
}

Create a Domain Model

// Create entity with multiple attribute types
customer, _ := modelAPI.DomainModels.CreateEntity("Customer").
    Persistent().
    WithStringAttribute("Name", 200).
    WithStringAttribute("Email", 254).
    WithIntegerAttribute("Age").
    WithBooleanAttribute("IsActive").
    WithDateTimeAttribute("CreatedDate").
    Build()

// Create another entity
order, _ := modelAPI.DomainModels.CreateEntity("Order").
    Persistent().
    WithDecimalAttribute("TotalAmount").
    WithDateTimeAttribute("OrderDate").
    Build()

// Create an enumeration
_, _ = modelAPI.Enumerations.CreateEnumeration("OrderStatus").
    WithValue("Pending", "Pending").
    WithValue("Processing", "Processing").
    WithValue("Completed", "Completed").
    WithValue("Cancelled", "Cancelled").
    Build()

// Create association
_, _ = modelAPI.DomainModels.CreateAssociation("Customer_Orders").
    From("Customer").
    To("Order").
    OneToMany().
    Build()

Create a Microflow

_, _ = modelAPI.Microflows.CreateMicroflow("ACT_ProcessOrder").
    WithParameter("Order", "Sales.Order").
    WithStringParameter("Message").
    ReturnsBoolean().
    Build()

Create a Page

_, _ = modelAPI.Pages.CreatePage("CustomerOverview").
    WithTitle("Customer Overview").
    Build()

Complete Module Setup

This example creates a full module with entities, enumerations, associations, and microflows:

package main

import (
    "log"
    "github.com/mendixlabs/mxcli/api"
)

func main() {
    modelAPI, err := api.Open("/path/to/MyApp.mpr")
    if err != nil {
        log.Fatal(err)
    }
    defer modelAPI.Close()

    module, _ := modelAPI.Modules.Get("ProductCatalog")
    modelAPI.SetModule(module)

    // Enumeration
    _, _ = modelAPI.Enumerations.CreateEnumeration("ProductCategory").
        WithValue("Electronics", "Electronics").
        WithValue("Clothing", "Clothing").
        WithValue("HomeGarden", "Home & Garden").
        Build()

    // Entities
    _, _ = modelAPI.DomainModels.CreateEntity("Product").
        Persistent().
        WithStringAttribute("SKU", 50).
        WithStringAttribute("Name", 200).
        WithDecimalAttribute("Price").
        WithIntegerAttribute("StockQuantity").
        WithBooleanAttribute("IsActive").
        WithEnumerationAttribute("Category", "ProductCatalog.ProductCategory").
        Build()

    _, _ = modelAPI.DomainModels.CreateEntity("Supplier").
        Persistent().
        WithStringAttribute("CompanyName", 200).
        WithStringAttribute("ContactEmail", 254).
        Build()

    // Association
    _, _ = modelAPI.DomainModels.CreateAssociation("Product_Supplier").
        From("Product").
        To("Supplier").
        OneToMany().
        Build()

    // Microflow
    _, _ = modelAPI.Microflows.CreateMicroflow("ValidateProduct").
        WithParameter("Product", "ProductCatalog.Product").
        ReturnsBoolean().
        Build()
}

Comparison: Fluent API vs Direct SDK

The fluent API reduces boilerplate. Here is the same entity creation using both approaches:

Fluent API

entity, _ := modelAPI.DomainModels.CreateEntity("Customer").
    Persistent().
    WithStringAttribute("Name", 200).
    WithIntegerAttribute("Age").
    Build()

Direct SDK

import (
    "github.com/mendixlabs/mxcli/model"
    "github.com/mendixlabs/mxcli/sdk/domainmodel"
    modelsdk "github.com/mendixlabs/mxcli"
)

entity := &domainmodel.Entity{
    BaseElement: model.BaseElement{
        ID: model.ID(modelsdk.GenerateID()),
    },
    Name:        "Customer",
    Persistable: true,
    Attributes: []*domainmodel.Attribute{
        {
            BaseElement: model.BaseElement{ID: model.ID(modelsdk.GenerateID())},
            Name:        "Name",
            Type:        &domainmodel.StringAttributeType{Length: 200},
        },
        {
            BaseElement: model.BaseElement{ID: model.ID(modelsdk.GenerateID())},
            Name:        "Age",
            Type:        &domainmodel.IntegerAttributeType{},
        },
    },
}

err := writer.CreateEntity(domainModelID, entity)

The fluent API handles ID generation, domain model lookup, and module context automatically.

Connection Statements

Statements for opening and closing Mendix project files.

StatementDescription
OPEN PROJECTOpen a Mendix project file for reading or modification
CLOSE PROJECTClose the currently open project and release the file handle

OPEN PROJECT

Synopsis

CONNECT LOCAL 'path/to/app.mpr'

Description

Opens a Mendix project file (.mpr) for reading and modification. The path can be absolute or relative to the current working directory. Once connected, all query and mutation statements operate against this project.

MDL supports both MPR v1 (single .mpr SQLite file, Mendix < 10.18) and MPR v2 (.mpr metadata + mprcontents/ folder, Mendix >= 10.18). The format is detected automatically.

Only one project can be open at a time. If a project is already open, close it with DISCONNECT before opening another.

Parameters

path
Path to the .mpr file. Can be an absolute path or a path relative to the current working directory. The path must be enclosed in single quotes.

Examples

Open a project with an absolute path

CONNECT LOCAL '/Users/dev/projects/MyApp/MyApp.mpr';

Open a project with a relative path

CONNECT LOCAL './mx-test-projects/test1-go-app/test1-go.mpr';

Open via the CLI flag

Instead of using CONNECT LOCAL inside the REPL, you can specify the project path when launching the CLI:

-- These are shell commands, not MDL:
-- mxcli -p /path/to/app.mpr
-- mxcli -p app.mpr -c "LIST ENTITIES"

See Also

CLOSE PROJECT, SHOW STATUS

CLOSE PROJECT

Synopsis

DISCONNECT

Description

Closes the currently open project and releases the file handle. Any uncommitted changes are discarded. After disconnecting, query and mutation statements are unavailable until a new project is opened with CONNECT LOCAL.

Parameters

This statement takes no parameters.

Examples

Close the current project

DISCONNECT;

Open a different project

DISCONNECT;
CONNECT LOCAL '/path/to/other-project.mpr';

See Also

OPEN PROJECT, SHOW STATUS

Query Statements

Statements for browsing and inspecting project elements. Query statements are read-only and never modify the project.

SHOW Statements

StatementDescription
LIST MODULESList all modules in the project
LIST ENTITIESList entities, optionally filtered by module
LIST ASSOCIATIONSList associations, optionally filtered by module
LIST ENUMERATIONSList enumerations, optionally filtered by module
LIST MICROFLOWSList microflows or nanoflows
LIST PAGESList pages or snippets
LIST CONSTANTSList constants, optionally filtered by module
LIST WORKFLOWSList workflows, optionally filtered by module
LIST BUSINESS EVENTSList business event services
DESCRIBE STRUCTUREHierarchical project overview at configurable depth
LIST WIDGETSList widgets across pages with optional filtering

DESCRIBE Statements

StatementDescription
DESCRIBE ENTITYShow complete MDL source for an entity
DESCRIBE ASSOCIATIONShow association details
DESCRIBE ENUMERATIONShow enumeration values and documentation
DESCRIBE MICROFLOWShow complete MDL source for a microflow or nanoflow
DESCRIBE PAGEShow complete MDL source for a page or snippet
DESCRIBE WIDGETShow a widget’s properties, body containers and a working MDL example

Search

StatementDescription
SEARCHFull-text search across all project strings and source

LIST MODULES

Synopsis

LIST MODULES

Description

Lists all modules in the current project with their names. This is typically the first command used after connecting to a project to understand its structure.

Module names returned by this command are used as the IN <module> filter for other SHOW and DESCRIBE statements.

Parameters

This statement takes no parameters.

Examples

List all modules in the project:

LIST MODULES;

Use the result to explore a specific module:

LIST MODULES;
LIST ENTITIES IN MyFirstModule;

See Also

DESCRIBE STRUCTURE, LIST ENTITIES, LIST MICROFLOWS, LIST PAGES

LIST ENTITIES

Synopsis

LIST ENTITIES [IN <module>]

Description

Lists entities in the project. Without the IN clause, lists all entities across all modules. With IN <module>, restricts the listing to entities in the specified module.

SHOW ENTITY <qualified_name> printed a summary of one entity. It has no replacement that prints the same thing: use DESCRIBE ENTITY for the definition, or this listing for the summary columns. Without a language header it still runs and warns MDL-V1-SHOWSUMMARY; under mdl 1; it is an error.

Parameters

module
The name of the module to filter by. Only entities belonging to this module are shown.

Examples

List all entities in the project:

LIST ENTITIES;

List entities in a specific module:

LIST ENTITIES IN Sales;

Show the definition of a single entity:

DESCRIBE ENTITY Sales.Customer;

See Also

DESCRIBE ENTITY, LIST ASSOCIATIONS, LIST MODULES

LIST MICROFLOWS / LIST NANOFLOWS

Synopsis

LIST MICROFLOWS [IN <module>]

LIST NANOFLOWS [IN <module>]

Description

Lists microflows or nanoflows in the project. Without the IN clause, lists all microflows (or nanoflows) across all modules. With IN <module>, restricts the listing to the specified module.

Microflows run on the server side and can perform database operations, call external services, and execute Java actions. Nanoflows run on the client side and are used for offline-capable and low-latency logic.

Parameters

module
The name of the module to filter by. Only microflows or nanoflows belonging to this module are shown.

Examples

List all microflows in the project:

LIST MICROFLOWS;

List microflows in a specific module:

LIST MICROFLOWS IN Administration;

List all nanoflows:

LIST NANOFLOWS;

List nanoflows in a specific module:

LIST NANOFLOWS IN MyFirstModule;

See Also

DESCRIBE MICROFLOW, LIST PAGES, LIST MODULES

LIST PAGES / LIST SNIPPETS

Synopsis

LIST PAGES [IN <module>]

LIST SNIPPETS [IN <module>]

Description

Lists pages or snippets in the project. Without the IN clause, lists all pages (or snippets) across all modules. With IN <module>, restricts the listing to the specified module.

Pages are the user interface screens of a Mendix application. Snippets are reusable page fragments that can be embedded in multiple pages via SNIPPETCALL widgets.

Parameters

module
The name of the module to filter by. Only pages or snippets belonging to this module are shown.

Examples

List all pages in the project:

LIST PAGES;

List pages in a specific module:

LIST PAGES IN Sales;

List all snippets:

LIST SNIPPETS;

List snippets in a specific module:

LIST SNIPPETS IN Common;

See Also

DESCRIBE PAGE, LIST WIDGETS, LIST MODULES

LIST ENUMERATIONS

Synopsis

LIST ENUMERATIONS [IN <module>]

Description

Lists enumerations in the project. Without the IN clause, lists all enumerations across all modules. With IN <module>, restricts the listing to enumerations in the specified module.

Enumerations define a fixed set of named values that can be used as attribute types on entities.

Parameters

module
The name of the module to filter by. Only enumerations belonging to this module are shown.

Examples

List all enumerations in the project:

LIST ENUMERATIONS;

List enumerations in a specific module:

LIST ENUMERATIONS IN Sales;

See Also

DESCRIBE ENUMERATION, LIST ENTITIES, LIST MODULES

LIST ASSOCIATIONS

Synopsis

LIST ASSOCIATIONS [IN <module>]

SHOW ASSOCIATION <qualified_name>

Description

Lists associations in the project. Without the IN clause, lists all associations across all modules. With IN <module>, restricts the listing to associations in the specified module.

The SHOW ASSOCIATION variant displays a summary of a single association by its qualified name, including the FROM entity, TO entity, and association type.

Parameters

module
The name of the module to filter by. Only associations belonging to this module are shown.
qualified_name
A Module.AssociationName reference identifying a specific association.

Examples

List all associations in the project:

LIST ASSOCIATIONS;

List associations in a specific module:

LIST ASSOCIATIONS IN Sales;

Show a single association:

DESCRIBE ASSOCIATION Sales.Order_Customer;

See Also

DESCRIBE ASSOCIATION, LIST ENTITIES, LIST MODULES

LIST CONSTANTS

Synopsis

LIST CONSTANTS [IN <module>]

LIST CONSTANT VALUES [IN <module>]

Description

Lists constants defined in the project. Without the IN clause, lists all constants across all modules. With IN <module>, restricts the listing to constants in the specified module.

Constants are named values (strings, integers, booleans, etc.) that can be configured per deployment environment. They are typically used for API URLs, feature flags, and configuration values.

LIST CONSTANT VALUES shows one row per constant per configuration, making it easy to compare constant overrides across configurations (e.g., Default, Acceptance, Production). Each constant’s default value is shown first, followed by any per-configuration overrides.

Parameters

module
The name of the module to filter by. Only constants belonging to this module are shown.

Examples

List all constants in the project:

LIST CONSTANTS;

List constants in a specific module:

LIST CONSTANTS IN MyModule;

Compare constant values across all configurations:

LIST CONSTANT VALUES;

Compare constant values for a specific module:

LIST CONSTANT VALUES IN MyModule;

See Also

LIST MODULES, DESCRIBE STRUCTURE, SHOW / DESCRIBE SETTINGS

LIST WORKFLOWS

Synopsis

LIST WORKFLOWS [IN <module>]

Description

Lists workflows defined in the project. Without the IN clause, lists all workflows across all modules. With IN <module>, restricts the listing to workflows in the specified module.

Workflows model multi-step business processes with user tasks, decisions, parallel paths, and microflow calls.

Parameters

module
The name of the module to filter by. Only workflows belonging to this module are shown.

Examples

List all workflows in the project:

LIST WORKFLOWS;

List workflows in a specific module:

LIST WORKFLOWS IN Approvals;

See Also

LIST MODULES, LIST MICROFLOWS, DESCRIBE STRUCTURE

LIST BUSINESS EVENTS

Synopsis

LIST BUSINESS EVENTS [IN <module>]

Description

Lists business event services defined in the project. Without the IN clause, lists all business event services across all modules. With IN <module>, restricts the listing to services in the specified module.

Business event services enable asynchronous, event-driven communication between Mendix applications using the Mendix Business Events platform.

Parameters

module
The name of the module to filter by. Only business event services belonging to this module are shown.

Examples

List all business event services:

LIST BUSINESS EVENTS;

List business event services in a specific module:

LIST BUSINESS EVENTS IN OrderModule;

See Also

LIST MODULES, DESCRIBE STRUCTURE

LIST IMAGE COLLECTIONS

Synopsis

LIST IMAGE COLLECTIONS;
LIST IMAGE COLLECTIONS IN module;

Description

Lists all image collections in the project. Use IN module to filter to a specific module. For full details including embedded images, use DESCRIBE IMAGE COLLECTION.

Parameters

IN module
Optional. Restricts the listing to collections in the specified module.

Examples

List all collections

LIST IMAGE COLLECTIONS;

Filter by module

LIST IMAGE COLLECTIONS IN MyModule;

Describe a specific collection

DESCRIBE IMAGE COLLECTION MyModule.AppIcons;

See Also

DESCRIBE IMAGE COLLECTION, CREATE IMAGE COLLECTION, LIST MODULES

DESCRIBE STRUCTURE

Synopsis

DESCRIBE STRUCTURE [DEPTH 1|2|3] [IN <module>] [ALL]

Description

Shows a hierarchical overview of the project structure. The output depth and scope can be controlled with optional clauses. By default, displays depth 2 (documents with signatures) for user modules only, excluding system and marketplace modules.

This is useful for getting a quick birds-eye view of what a project contains without examining each element individually.

Parameters

DEPTH
Controls the level of detail in the output. 1 shows one line per module with element counts. 2 (default) shows documents with their signatures. 3 shows full detail including typed attributes and named parameters.
module
When IN <module> is specified, restricts output to a single module. Implicitly uses depth 2 unless overridden.
ALL
Include system and marketplace modules in the output. Without this flag, only user-created modules are shown.

Examples

Show module-level summary (one line per module):

DESCRIBE STRUCTURE DEPTH 1;

Show default structure (documents with signatures, user modules only):

DESCRIBE STRUCTURE;

Describe structure of a single module:

DESCRIBE STRUCTURE IN MyFirstModule;

Show full detail including all modules:

DESCRIBE STRUCTURE DEPTH 3 ALL;

See Also

LIST MODULES, LIST ENTITIES, LIST MICROFLOWS, LIST PAGES

LIST WIDGETS

Synopsis

LIST WIDGETS [IN <module>] [WHERE <condition>]

Description

Lists widgets used across pages in the project. This command queries the catalog and requires REFRESH CATALOG FULL to have been run first. Without filters, lists all widgets. The IN clause restricts results to a specific module, and the WHERE clause enables filtering on widget properties.

This is useful for auditing widget usage, finding pages that use a particular widget type, or planning bulk widget updates.

Parameters

module
The name of the module to filter by. Only widgets on pages belonging to this module are shown.
condition
A filter expression on widget properties. Supported filter columns include WidgetType, PageName, ModuleName, and other catalog fields.

Examples

List all widgets (requires catalog):

REFRESH CATALOG FULL;
LIST WIDGETS;

List widgets in a specific module:

LIST WIDGETS IN Sales;

Filter widgets by type:

LIST WIDGETS WHERE WidgetType = 'DataGrid';

See Also

LIST PAGES, DESCRIBE PAGE, DESCRIBE STRUCTURE

DESCRIBE ENTITY

Synopsis

DESCRIBE ENTITY <qualified_name>

Description

Shows the complete MDL source for an entity, including its type (persistent, non-persistent, view, external), all attributes with types and constraints, indexes, access rules, and documentation comments. The output is round-trippable MDL that can be used directly in CREATE ENTITY statements.

This is the primary way to inspect the full definition of an entity before modifying it with ALTER ENTITY or recreating it with CREATE OR MODIFY.

Parameters

qualified_name
A Module.EntityName reference identifying the entity to describe. Both the module and entity name are required.

Examples

Describe a customer entity:

DESCRIBE ENTITY Sales.Customer;

Example output:

/**
 * Customer entity stores customer information.
 */
@Position(100, 200)
CREATE PERSISTENT ENTITY Sales.Customer (
  /** Customer identifier */
  CustomerId: AutoNumber NOT NULL UNIQUE DEFAULT 1,
  Name: String(200) NOT NULL,
  Email: String(200),
  Status: Enumeration(Sales.CustomerStatus) DEFAULT 'Active'
)
INDEX (Name);

Describe a non-persistent entity:

DESCRIBE ENTITY Sales.CustomerFilter;

See Also

LIST ENTITIES, DESCRIBE ASSOCIATION, DESCRIBE ENUMERATION

DESCRIBE MICROFLOW / DESCRIBE NANOFLOW

Synopsis

DESCRIBE MICROFLOW <qualified_name>

DESCRIBE NANOFLOW <qualified_name>

Description

Shows the complete MDL source for a microflow or nanoflow, including parameters, return type, variable declarations, activities, control flow (IF/LOOP), error handling, and annotations. The output is round-trippable MDL that can be used directly in CREATE MICROFLOW or CREATE NANOFLOW statements.

This is useful for understanding existing logic before modifying it, or for extracting a microflow definition to use as a template.

Parameters

qualified_name
A Module.MicroflowName or Module.NanoflowName reference identifying the flow to describe. Both the module and flow name are required.

Examples

Describe a microflow:

DESCRIBE MICROFLOW Sales.ACT_CreateOrder;

Example output:

CREATE MICROFLOW Sales.ACT_CreateOrder
FOLDER 'Orders'
BEGIN
  DECLARE $Order Sales.Order;
  $Order = CREATE Sales.Order (
    OrderDate = [%CurrentDateTime%],
    Status = 'Draft'
  );
  COMMIT $Order;
  SHOW PAGE Sales.Order_Edit (Order = $Order);
  RETURN $Order;
END;

Describe a nanoflow:

DESCRIBE NANOFLOW MyModule.NAV_ValidateInput;

See Also

LIST MICROFLOWS, DESCRIBE PAGE, DESCRIBE ENTITY

DESCRIBE PAGE / DESCRIBE SNIPPET

Synopsis

DESCRIBE PAGE <qualified_name>

DESCRIBE SNIPPET <qualified_name>

Description

Shows the complete MDL source for a page or snippet, including page properties (title, layout, parameters), the full widget tree with all property values, and nested widget structures. The output is round-trippable MDL that can be used directly in CREATE PAGE or CREATE SNIPPET statements.

This is particularly useful before using ALTER PAGE or ALTER SNIPPET, as it reveals the widget names needed for targeted modifications.

Parameters

qualified_name
A Module.PageName or Module.SnippetName reference identifying the page or snippet to describe. Both the module and document name are required.

Examples

Describe a page:

DESCRIBE PAGE Sales.Customer_Edit;

Example output:

CREATE PAGE Sales.Customer_Edit
(
  Params: ( $Customer: Sales.Customer ),
  Title: 'Edit Customer',
  Layout: Atlas_Core.PopupLayout
)
{
  DATAVIEW dvCustomer (DataSource: $Customer) {
    TEXTBOX txtName (Label: 'Name', Attribute: Name)
    TEXTBOX txtEmail (Label: 'Email', Attribute: Email)
    FOOTER {
      ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)
      ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
    }
  }
};

Describe a snippet:

DESCRIBE SNIPPET Common.NavigationMenu;

See Also

LIST PAGES, LIST WIDGETS, DESCRIBE MICROFLOW

DESCRIBE ENUMERATION

Synopsis

DESCRIBE ENUMERATION <qualified_name>

Description

Shows the complete MDL source for an enumeration, including all values with their captions and any documentation comments. The output is round-trippable MDL that can be used directly in a CREATE ENUMERATION statement.

Enumerations define a fixed set of named values used as attribute types on entities.

Parameters

qualified_name
A Module.EnumerationName reference identifying the enumeration to describe. Both the module and enumeration name are required.

Examples

Describe an enumeration:

DESCRIBE ENUMERATION Sales.OrderStatus;

Example output:

/** Order status enumeration */
CREATE ENUMERATION Sales.OrderStatus (
  Draft 'Draft',
  Pending 'Pending Approval',
  Approved 'Approved',
  Shipped 'Shipped',
  Delivered 'Delivered',
  Cancelled 'Cancelled'
);

See Also

LIST ENUMERATIONS, DESCRIBE ENTITY, LIST MODULES

DESCRIBE ASSOCIATION

Synopsis

DESCRIBE ASSOCIATION <qualified_name>

Description

Shows the complete MDL source for an association, including the FROM entity, TO entity, association type (Reference or ReferenceSet), owner, and delete behavior. The output is round-trippable MDL that can be used directly in a CREATE ASSOCIATION statement.

Associations define relationships between entities. A Reference type is a one-to-many relationship, while ReferenceSet is many-to-many.

Parameters

qualified_name
A Module.AssociationName reference identifying the association to describe. Both the module and association name are required.

Examples

Describe an association:

DESCRIBE ASSOCIATION Sales.Order_Customer;

Example output:

/** Links orders to customers */
CREATE ASSOCIATION Sales.Order_Customer
  FROM Sales.Customer
  TO Sales.Order
  TYPE Reference
  OWNER Default
  ON DELETE SET NULL;

Describe a many-to-many association:

DESCRIBE ASSOCIATION Sales.Order_Product;

See Also

LIST ASSOCIATIONS, DESCRIBE ENTITY, LIST MODULES

SEARCH

Synopsis

SEARCH '<keyword>'

Description

Performs a full-text search across all strings, messages, captions, the documentation of project elements (entities, attributes, constants, pages and more), and MDL source in the project. Uses FTS5 (SQLite full-text search) for fast matching. Returns results grouped by document type and location.

This command requires the catalog to be populated. If search returns no results, run REFRESH CATALOG FULL first. The search is also available as a CLI subcommand: mxcli search -p app.mpr "keyword" --format names|json. The CLI adds --exclude-marketplace; see Full-Text Search.

Parameters

keyword
The search term to look for. Must be enclosed in single quotes. Matches against entity names, attribute names, microflow activity captions, page titles, widget labels, string constants, log messages, element documentation, and MDL source text.

Examples

Search for a keyword:

SEARCH 'Customer';

Search for an error message:

SEARCH 'is required';

Search using the CLI:

-- From the command line:
-- mxcli search -p app.mpr "Customer" --format names
-- mxcli search -p app.mpr "Customer" --json

See Also

DESCRIBE STRUCTURE, LIST ENTITIES, LIST MICROFLOWS

Domain Model Statements

Statements for creating, altering, and dropping domain model elements: entities, enumerations, associations, and constants.

Entity Statements

StatementDescription
CREATE ENTITYCreate a new entity in a module’s domain model
ALTER ENTITYAdd, drop, modify, or rename attributes and indexes on an existing entity
DROP ENTITYRemove an entity from the domain model

Enumeration Statements

StatementDescription
CREATE ENUMERATIONCreate a new enumeration type with named values
DROP ENUMERATIONRemove an enumeration from the project

Association Statements

StatementDescription
CREATE ASSOCIATIONCreate a relationship between two entities
DROP ASSOCIATIONRemove an association from the domain model

Constant Statements

StatementDescription
CREATE CONSTANTCreate a named constant value in a module

CREATE ENTITY

Synopsis

CREATE [ OR MODIFY ] entity_type ENTITY module.name
    [ EXTENDS parent_module.parent_entity ]
(
    attribute_name : data_type [ NOT LOCALIZED | LOCALIZED ]
                               [ NOT NULL [ ERROR 'message' ] ]
                               [ UNIQUE [ ERROR 'message' ] ]
                               [ DEFAULT value ]
                               [ CALCULATED [ BY module.microflow ] ]
    [, ...]
)
[ INDEX ( column [ ASC | DESC ] [, ...] ) ]
[, ...]

CREATE VIEW ENTITY module.name (
    attribute_name : data_type [, ...]
) AS
    oql_query

Where entity_type is one of:

PERSISTENT
NON-PERSISTENT
EXTERNAL

Description

CREATE ENTITY adds a new entity to a module’s domain model. The entity type determines how instances are stored:

  • PERSISTENT entities are backed by a database table. This is the most common type and is required for data that must survive a server restart.
  • NON-PERSISTENT entities exist only in memory for the duration of a user session. They are commonly used for search filters, wizard state, and transient view models.
  • VIEW entities are read-only entities backed by an OQL query. They appear in the domain model but do not have their own database table.
  • EXTERNAL entities are sourced from an external service (e.g., OData). See CREATE EXTERNAL ENTITY for the full syntax.

If OR MODIFY is specified, the statement is idempotent: if an entity with the same qualified name already exists, its attributes are updated to match the definition. New attributes are added, and existing attributes have their types and constraints updated. This is useful for repeatable scripts.

The optional EXTENDS clause establishes generalization (inheritance). The child entity inherits all attributes from the parent and can add its own. Common parent entities include System.User, System.FileDocument, and System.Image. The EXTENDS clause must appear before the opening parenthesis. A specialization inherits its persistability from the root of the generalization chain, which is the only place Mendix stores it: PERSISTENT or NON-PERSISTENT on it may restate the inherited value, and one that contradicts it is an error from both check and exec. Leave the keyword out to take whatever the chain says.

Each attribute definition specifies a name, a data type, and optional constraints. Attributes are separated by commas. The supported data types are:

TypeSyntaxNotes
StringString(length)Length in characters; use String(unlimited) for unbounded
IntegerInteger32-bit signed integer
LongLong64-bit signed integer
DecimalDecimalArbitrary-precision decimal
BooleanBooleanTRUE or FALSE; defaults to FALSE when no DEFAULT is given
DateTimeDateTimeDate and time combined
AutoNumberAutoNumberAuto-incrementing integer (persistent entities only)
BinaryBinaryBinary data
HashedStringHashedStringOne-way hashed string (for passwords)
EnumerationEnumeration(Module.EnumName)Reference to an enumeration type

Documentation comments (/** ... */) placed immediately before the entity or an individual attribute are preserved as documentation in the domain model.

The @Position(x, y) annotation controls the entity’s visual position in the domain model diagram in Mendix Studio Pro.

One or more INDEX clauses may follow the attribute list to create database indexes. Each index lists one or more columns with an optional sort direction (ASC or DESC, defaulting to ASC).

Parameters

OR MODIFY
Makes the statement idempotent. If the entity already exists, its definition is updated to match. New attributes are added and existing attributes are modified. Without this clause, creating a duplicate entity is an error.
entity_type
One of PERSISTENT, NON-PERSISTENT, VIEW, or EXTERNAL. Determines how entity instances are stored. PERSISTENT is the most common choice.
module.name
The qualified name of the entity in the form Module.EntityName. The module must already exist.
EXTENDS parent_module.parent_entity
Establishes generalization (inheritance). The new entity inherits all attributes from the parent. Must appear before the opening parenthesis.
attribute_name
The name of an attribute. Must be a valid identifier and unique within the entity.
data_type
The attribute’s data type. See the table above for all supported types. String requires an explicit length argument.
NOT LOCALIZED | LOCALIZED
DateTime only: Studio Pro’s Localize setting (LocalizeDate). NOT LOCALIZED stores and shows the value without time-zone conversion (a calendar date such as a birth date); LOCALIZED states the default. On a new attribute no clause means localized; with OR MODIFY, no clause keeps the stored value and a stated one wins. On any other type it is an error. See Constraints.
NOT NULL
Marks the attribute as required. An optional ERROR 'message' clause provides a custom validation message shown when the constraint is violated.
UNIQUE
Marks the attribute as having a uniqueness constraint. An optional ERROR 'message' clause provides a custom validation message.
DEFAULT value
Sets the default value assigned to the attribute when a new object is created. String defaults are quoted ('value'), numeric defaults are bare (0, 3.14), and boolean defaults are TRUE or FALSE.
CALCULATED
Marks the attribute as calculated (not stored in the database). The attribute’s value is derived at runtime. Use CALCULATED BY Module.Microflow to specify the microflow that computes the value.
INDEX ( column [, …] )
Creates a database index on the listed columns. Each column may have an optional ASC or DESC sort direction. Multiple INDEX clauses create multiple indexes.

Examples

Basic persistent entity

CREATE PERSISTENT ENTITY Sales.Product (
    Name: String(200) NOT NULL,
    Price: Decimal DEFAULT 0,
    InStock: Boolean DEFAULT TRUE
);

Entity with all constraint types

/** Customer master data */
@Position(100, 200)
CREATE PERSISTENT ENTITY Sales.Customer (
    /** Unique customer identifier */
    CustomerId: AutoNumber NOT NULL UNIQUE DEFAULT 1,

    /** Customer full name */
    Name: String(200) NOT NULL ERROR MESSAGE 'Name is required',

    Email: String(200) UNIQUE ERROR MESSAGE 'Email must be unique',

    Balance: Decimal DEFAULT 0,

    IsActive: Boolean DEFAULT TRUE,

    CreatedDate: AutoCreatedDate,

    Status: Enumeration(Sales.CustomerStatus) DEFAULT 'Active'
)
INDEX (Name)
INDEX (Email);

Entity with generalization (EXTENDS)

/** Employee extends the system user entity */
CREATE PERSISTENT ENTITY HR.Employee EXTENDS System.User (
    EmployeeNumber: String(20) NOT NULL UNIQUE,
    Department: String(100),
    HireDate: DateTime
);

File and image entities

mdl 1;
CREATE PERSISTENT ENTITY Catalog.ProductPhoto EXTENDS System.Image (
    Caption: String(200),
    SortOrder: Integer DEFAULT 0
);

CREATE PERSISTENT ENTITY Docs.Attachment EXTENDS System.FileDocument (
    Description: String(500)
);

Non-persistent entity (search filter)

CREATE NON-PERSISTENT ENTITY Sales.CustomerFilter (
    SearchName: String(200),
    MinBalance: Decimal,
    MaxBalance: Decimal,
    IncludeInactive: Boolean DEFAULT FALSE
);

Idempotent script with OR MODIFY

CREATE OR MODIFY PERSISTENT ENTITY Sales.Customer (
    CustomerId: AutoNumber NOT NULL UNIQUE DEFAULT 1,
    Name: String(200) NOT NULL,
    Email: String(200),
    Phone: String(50)
);

If Sales.Customer already exists, the Phone attribute is added and existing attributes are updated. If it does not exist, it is created.

View entity with OQL query

CREATE VIEW ENTITY Reports.CustomerSummary (
    CustomerName: String,
    TotalOrders: Integer,
    TotalAmount: Decimal
) AS
    SELECT
        c.Name AS CustomerName,
        COUNT(o.OrderId) AS TotalOrders,
        SUM(o.Amount) AS TotalAmount
    FROM Sales.Customer c
    LEFT JOIN Sales.Order o ON o.Customer = c
    GROUP BY c.Name;

Entity with calculated attribute

CREATE PERSISTENT ENTITY Sales.Order (
    OrderNumber: String(50) NOT NULL UNIQUE,
    Subtotal: Decimal DEFAULT 0,
    TaxRate: Decimal DEFAULT 0,
    TotalAmount: Decimal CALCULATED BY Sales.CalcOrderTotal
);

Entity with composite index

CREATE PERSISTENT ENTITY Sales.Order (
    OrderId: AutoNumber NOT NULL UNIQUE DEFAULT 1,
    OrderNumber: String(50) NOT NULL UNIQUE,
    CustomerId: Long,
    OrderDate: DateTime,
    Status: Enumeration(Sales.OrderStatus)
)
INDEX (OrderNumber)
INDEX (CustomerId, OrderDate DESC)
INDEX (Status);

Entity with documentation

/**
 * Represents a customer order.
 * Orders track purchases from initial draft through delivery.
 */
@Position(300, 100)
CREATE PERSISTENT ENTITY Sales.Order (
    /** Auto-generated order identifier */
    OrderId: AutoNumber NOT NULL UNIQUE DEFAULT 1,

    /** Human-readable order number */
    OrderNumber: String(50) NOT NULL UNIQUE,

    OrderDate: DateTime NOT NULL,
    TotalAmount: Decimal DEFAULT 0,
    Status: Enumeration(Sales.OrderStatus) DEFAULT 'Draft',
    Notes: String(unlimited)
)
INDEX (OrderNumber)
INDEX (OrderDate DESC);

Notes

  • String requires an explicit length: String(200). Use String(unlimited) for unbounded text.
  • EXTENDS must appear before the opening parenthesis, not after the closing one.
  • A member name must be unique along the generalization chain: a specialization cannot declare an attribute or own an association named like a member it inherits (from System.User and the other System entities too), and a generalization cannot gain one a specialization already has. An entity’s attributes and associations share that name space; names differing only in case do not collide. mxcli check refuses it (MDL-MEMBER01); Mendix reports it as CE0069 “Duplicate member name”.
  • Boolean attributes without an explicit DEFAULT automatically default to FALSE.
  • AutoNumber attributes are only valid on persistent entities.
  • The @Position annotation is optional and only affects the visual layout in Mendix Studio Pro.
  • Every statement ends with ;.

See Also

ALTER ENTITY, DROP ENTITY, CREATE ASSOCIATION, CREATE ENUMERATION

ALTER ENTITY

Synopsis

ALTER ENTITY module.name ADD ATTRIBUTE attribute_definition

ALTER ENTITY module.name DROP ATTRIBUTE attribute_name

ALTER ENTITY module.name MODIFY ATTRIBUTE attribute_definition

ALTER ENTITY module.name RENAME ATTRIBUTE old_name TO new_name

ALTER ENTITY module.name ADD INDEX [ index_name ] ( column [ ASC | DESC ] [, ...] )

ALTER ENTITY module.name DROP INDEX index_name

ALTER ENTITY module.name SET DOCUMENTATION 'text'

Description

ALTER ENTITY modifies an existing entity without replacing it entirely. This is useful for incremental changes to a domain model – adding a new attribute, removing an obsolete one, renaming for clarity, or managing indexes.

Each ALTER ENTITY statement performs exactly one operation. To make multiple changes, issue multiple statements.

The ADD ATTRIBUTE operation appends a new attribute to the entity. Attribute definitions follow the same syntax as in CREATE ENTITY: a name, a data type, and optional constraints (NOT NULL, UNIQUE, DEFAULT).

The DROP ATTRIBUTE operation removes an attribute by name. Dropping an attribute also removes any validation rules and index entries that reference it.

The MODIFY ATTRIBUTE operation changes the type or constraints of an existing attribute. The attribute name must already exist in the entity. The full attribute definition (type and constraints) replaces the current one.

The RENAME ATTRIBUTE operation changes an attribute’s name and rewrites every reference Mendix stores as a reference — microflow create and change activity members, page attribute widgets, and the entity’s own validation and access rules — reporting how many documents it touched. Renaming onto a name the entity already uses is refused.

XPath constraints are rewritten too, even though they name the attribute as a bare step. Each constraint’s target entity is known from where it is stored — a retrieve names its entity, a widget data source names its entity, an access rule lives inside one — and every further hop is an association or entity named in the path, so [Phone = '...'] and [Sales.Order_Customer/Sales.Customer/Phone = '...'] both follow the rename while another entity’s identically-named attribute does not. A constraint mxcli cannot resolve is listed in a warning and left exactly as it was.

One kind of use is not rewritten: microflow expressions ($obj/Phone), where a bare name is only resolvable from the type of what precedes it. The command prints a note saying so, and mx check reports the leftovers as CE0117.

The ADD INDEX and DROP INDEX operations manage database indexes. ADD INDEX takes an optional index name followed by one or more columns, each with an optional ASC or DESC sort direction; DROP INDEX takes the index name.

The SET DOCUMENTATION operation replaces the entity’s documentation string.

Parameters

module.name
The qualified name of the entity to modify, in the form Module.EntityName.
attribute_definition
An attribute name followed by a colon, data type, and optional constraints. Same syntax as in CREATE ENTITY.
attribute_name
The name of an existing attribute to drop.
old_name
The current name of the attribute to rename.
new_name
The new name for the attribute.
column
An attribute name for the index, optionally followed by ASC or DESC.
index_name
The name of an index. Optional when adding an index; required when dropping one.
text
A single-quoted string to use as the entity’s documentation.

Examples

Add new attributes

mdl 1;
ALTER ENTITY Sales.Customer ADD ATTRIBUTE Phone: String(50);
ALTER ENTITY Sales.Customer ADD ATTRIBUTE Notes: String(unlimited);

Drop an attribute

ALTER ENTITY Sales.Customer DROP ATTRIBUTE Notes;

Modify an attribute’s type

ALTER ENTITY Sales.Customer MODIFY ATTRIBUTE Phone: String(100) NOT NULL;

Rename an attribute

ALTER ENTITY Sales.Customer RENAME ATTRIBUTE Phone TO PhoneNumber;

Add an index

ALTER ENTITY Sales.Customer
    ADD INDEX (Email);

Add a composite index with sort direction

ALTER ENTITY Sales.Order
    ADD INDEX (CustomerId, OrderDate DESC);

Drop an index

ALTER ENTITY Sales.Customer DROP INDEX idx_customer_email;

Set documentation

ALTER ENTITY Sales.Customer
    SET DOCUMENTATION 'Customer master data for the sales module.';

Set position

Reposition an entity on the domain model canvas:

ALTER ENTITY Sales.Customer
    SET POSITION (100, 200);

Notes

  • Each ALTER ENTITY statement performs a single operation. Chain multiple statements for multiple changes.
  • RENAME ATTRIBUTE updates references in microflows, pages and access rules, and rewrites XPath constraints. It does not rewrite microflow expressions ($obj/Attr); use LIST IMPACT OF to find the documents to check, or build and read the CE0117s.
  • DROP removes the attribute’s validation rules and index entries automatically.

See Also

CREATE ENTITY, DROP ENTITY

DROP ENTITY

Synopsis

DROP ENTITY module.name

Description

DROP ENTITY removes an entity from a module’s domain model. The entity and all of its attributes, validation rules, indexes, and access rules are deleted.

Every association with the dropped entity at either end is removed with it — including a cross-module association declared in another module. The access rules of the entities at the other end are reconciled afterwards, in whichever module they live, so no rule is left naming an association that no longer exists (Mendix would reject that with CE1613):

Reconciled 1 access rule(s) in module Shop after dropping the entity
Dropped entity: Crm.Contact

Microflows, pages and other documents that use the entity or one of those associations are not changed.

Use LIST IMPACT OF Module.EntityName to check which microflows, pages, and associations reference the entity before dropping it.

Parameters

module.name
The qualified name of the entity to remove, in the form Module.EntityName.

Examples

Drop a single entity

DROP ENTITY Sales.CustomerFilter;

Drop entity after checking impact

mdl 1;
-- Check what references this entity
LIST IMPACT OF Sales.OldEntity;

-- Drops the entity and its associations, and reconciles the access rules
DROP ENTITY Sales.OldEntity;

See Also

CREATE ENTITY, ALTER ENTITY, DROP ASSOCIATION

CREATE ENUMERATION

Synopsis

CREATE [ OR MODIFY ] ENUMERATION module.name (
    value_name 'caption'
    [, ...]
)

Description

CREATE ENUMERATION defines a new enumeration type in a module. An enumeration is a fixed set of named values that can be used as an attribute type on entities (via Enumeration(Module.EnumName)).

Each enumeration value has two parts: a value name (an identifier used in code and expressions) and a caption (a display string shown in the UI). The value name must be a valid identifier. The caption is a single-quoted string.

If OR MODIFY is specified, the statement is idempotent. If the enumeration already exists, its values are updated to match the definition.

A documentation comment (/** ... */) placed before the statement is preserved as the enumeration’s documentation.

Parameters

OR MODIFY
Makes the statement idempotent. If the enumeration already exists, its values are replaced with the new definition. Without this clause, creating a duplicate enumeration is an error.
module.name
The qualified name of the enumeration in the form Module.EnumerationName. The module must already exist.
value_name
An identifier for the enumeration value. Used in expressions and microflow logic.
caption
A single-quoted display string for the value. This is what end users see in the UI.

Examples

Basic enumeration

CREATE ENUMERATION Sales.OrderStatus (
    Draft 'Draft',
    Pending 'Pending Approval',
    Approved 'Approved',
    Shipped 'Shipped',
    Delivered 'Delivered',
    Cancelled 'Cancelled'
);

Enumeration with documentation

/** Priority levels for support tickets */
CREATE ENUMERATION Support.TicketPriority (
    Low 'Low',
    Medium 'Medium',
    High 'High',
    Critical 'Critical'
);

Idempotent with OR MODIFY

CREATE OR MODIFY ENUMERATION Sales.OrderStatus (
    Draft 'Draft',
    Pending 'Pending Approval',
    Approved 'Approved',
    Shipped 'Shipped',
    Delivered 'Delivered',
    Returned 'Returned',
    Cancelled 'Cancelled'
);

Using an enumeration as an attribute type

CREATE PERSISTENT ENTITY Sales.Order (
    OrderNumber: String(50) NOT NULL,
    Status: Enumeration(Sales.OrderStatus) DEFAULT 'Draft'
);

Notes

  • Enumeration values can also be managed incrementally with ALTER ENUMERATION ... ADD VALUE and ALTER ENUMERATION ... REMOVE VALUE.
  • The DEFAULT value on an entity attribute references the enumeration value name as a quoted string (e.g., DEFAULT 'Draft').

See Also

DROP ENUMERATION, CREATE ENTITY

DROP ENUMERATION

Synopsis

DROP ENUMERATION module.name

Description

DROP ENUMERATION removes an enumeration type from the project. Any entity attributes that reference the enumeration as their type (via Enumeration(Module.EnumName)) will become invalid after the enumeration is dropped.

Use LIST IMPACT OF Module.EnumName to check which entities and attributes reference the enumeration before dropping it.

Parameters

module.name
The qualified name of the enumeration to remove, in the form Module.EnumerationName.

Examples

Drop an enumeration

DROP ENUMERATION Sales.OrderStatus;

Drop after checking references

mdl 1;
LIST IMPACT OF Sales.OldStatus;
DROP ENUMERATION Sales.OldStatus;

See Also

CREATE ENUMERATION

CREATE ASSOCIATION

Synopsis

CREATE [ OR MODIFY ] ASSOCIATION module.name
    FROM from_module.from_entity
    TO to_module.to_entity
    TYPE { Reference | ReferenceSet }
    [ OWNER { Default | Both | Parent | Child } ]
    [ ON DELETE { SET NULL | CASCADE | RESTRICT } [ ERROR MESSAGE 'text' ] ]

Description

CREATE ASSOCIATION defines a relationship between two entities in the domain model.

The FROM entity is the side that owns the foreign key (in database terms). The TO entity is the side being referenced. For a typical one-to-many relationship like “a Customer has many Orders,” the FROM side is the parent entity (Customer) and the TO side is the child entity (Order).

Two association types are supported:

  • Reference – a many-to-one relationship. Each object on the TO side references at most one object on the FROM side. Stored as a foreign key column in the TO entity’s database table.
  • ReferenceSet – a many-to-many relationship. Objects on both sides can reference multiple objects on the other side. Stored in a separate junction table.

The OWNER clause controls which side of the association can modify the relationship:

OwnerDescription
DefaultThe default owner (child/TO side) can set and clear the reference
BothBoth sides can modify the association
ParentOnly the FROM side can modify the association
ChildOnly the TO side can modify the association

The ON DELETE clause controls what happens when an object on the FROM side is deleted:

ActionDescription
SET NULLDelete the object and set references to null (the default)
CASCADEDelete the object and all associated objects on the TO side
RESTRICTRefuse the delete while anything still references the object; ERROR MESSAGE '…' is the text the user sees

The older DELETE_BEHAVIOR clause (DELETE_BUT_KEEP_REFERENCES, DELETE_AND_REFERENCES / CASCADE, DELETE_IF_NO_REFERENCES / PREVENT) still parses with the same meaning, but is deprecated (MDL-DEPR022); mxcli fmt --upgrade rewrites it.

If OR MODIFY is specified, the statement is idempotent: if the association already exists, it is updated to match the new definition.

A documentation comment (/** ... */) placed before the statement is preserved as the association’s documentation.

Parameters

OR MODIFY

Makes the statement idempotent. If the association already exists, its properties are updated. Without this clause, creating a duplicate association is an error.

module.name

The qualified name of the association in the form Module.AssociationName. By convention, association names follow the pattern Module.Child_Parent or Module.EntityA_EntityB.

FROM from_module.from_entity

The entity on the “from” side of the relationship. This is the entity that owns the foreign key in the database. In a one-to-many relationship, this is typically the “one” (parent) side.

TO to_module.to_entity

The entity on the “to” side of the relationship. In a one-to-many relationship, this is typically the “many” (child) side that holds the reference.

TYPE

Either Reference (many-to-one) or ReferenceSet (many-to-many).

OWNER

Which side can modify the association. Defaults to Default if omitted.

Between a persistent and a non-persistent entity, Mendix allows one shape only: FROM the non-persistent entity, with owner Default. A persistent FROM end or OWNER Both there fails the build with CE0001, so mxcli check and exec refuse it (MDL-ASSOC01). ALTER ASSOCIATION … SET OWNER Both is refused the same way.

Under OWNER Both the association is a member of the TO entity too, so its access rules get an entry for it — in the TO entity’s module, for a cross-module association. CREATE OR MODIFY that moves the TO end, or changes the owner, reconciles the old and the new TO module’s rules.

ON DELETE

What happens to associated objects when a FROM-side object is deleted. If omitted, references are kept (the default Mendix behavior).

Examples

Many-to-one: Order belongs to Customer

CREATE ASSOCIATION Sales.Order_Customer
    FROM Sales.Customer
    TO Sales.Order
    TYPE Reference
    OWNER Default
    ON DELETE SET NULL;

Many-to-many: Order has Products

CREATE ASSOCIATION Sales.Order_Product
    FROM Sales.Order
    TO Sales.Product
    TYPE ReferenceSet
    OWNER Both;

Cascade delete: Invoice deleted with Order

/** Invoice must be deleted when its Order is deleted */
CREATE ASSOCIATION Sales.Order_Invoice
    FROM Sales.Order
    TO Sales.Invoice
    TYPE Reference
    ON DELETE CASCADE;

Idempotent with OR MODIFY

CREATE OR MODIFY ASSOCIATION Sales.Order_Customer
    FROM Sales.Customer
    TO Sales.Order
    TYPE Reference
    OWNER Default;

Cross-module association

CREATE ASSOCIATION HR.Employee_Department
    FROM HR.Department
    TO HR.Employee
    TYPE Reference;

Notes

  • The naming convention Module.Child_Parent reflects that the child (TO) entity holds the reference to the parent (FROM) entity. This can be counterintuitive – the FROM entity is the parent, and the TO entity is the child.
  • Internally, Mendix stores the FROM entity pointer as ParentPointer and the TO entity pointer as ChildPointer. These BSON field names are inverted relative to what you might expect.
  • Entity access rules for associations (member access) must only be added to the FROM entity. Adding them to the TO entity triggers validation errors.

See Also

DROP ASSOCIATION, CREATE ENTITY

DROP ASSOCIATION

Synopsis

DROP ASSOCIATION module.name

Description

DROP ASSOCIATION removes an association (relationship) between two entities from the domain model. The entities themselves are not affected – only the relationship is removed.

The access rules that named the association are reconciled: the entry is removed from the FROM entity’s rules and, for an OWNER Both association, from the TO entity’s — which for a cross-module association are in another module. A message definition that still exposes the association blocks the drop, naming the member to remove first.

Microflows and pages that traverse the association become invalid. Use LIST IMPACT OF Module.AssociationName to check references before dropping.

Parameters

module.name
The qualified name of the association to remove, in the form Module.AssociationName.

Examples

Drop an association

DROP ASSOCIATION Sales.Order_Customer;

Drop after checking impact

mdl 1;
LIST IMPACT OF Sales.OldAssociation;
DROP ASSOCIATION Sales.OldAssociation;

See Also

CREATE ASSOCIATION, DROP ENTITY

CREATE CONSTANT

Synopsis

[ /** documentation */ ]
CREATE [ OR MODIFY ] CONSTANT module.name [ FOLDER 'path' ] (
    Type: data_type,
    DefaultValue: value
    [ , ExposedToClient: true ]
)

Description

CREATE CONSTANT defines a named constant in a module. Constants hold configuration values (API URLs, feature flags, limits) that can differ between environments. In Mendix, constant values can be overridden per runtime configuration without changing the model.

The properties are a ( Key: value ) list, with the names Studio Pro gives them. Type is the constant’s data type. Supported types include String, Integer, Long, Decimal, Boolean, and DateTime.

DefaultValue is the value used at runtime unless overridden by environment configuration. Type and DefaultValue are required. ExposedToClient: true makes the value available to nanoflows and client-side code; it defaults to false.

The clause form TYPE data_type DEFAULT value [ EXPOSED TO CLIENT ] is the deprecated alias of the property list: it still parses and warns MDL-DEPR136, and mxcli fmt --upgrade rewrites it.

If OR MODIFY is specified, the statement is idempotent. If the constant already exists, its type and default value are updated.

Constant values can also be overridden per deployment configuration using ALTER SETTINGS CONSTANT.

FOLDER 'path' goes right after the name, as on every document. A FOLDER after the properties is the old position: it still parses and warns MDL-DEPR134, and mxcli fmt --upgrade moves it.

There is no PRIVATE modifier. Older scripts wrote one after the value; it was never stored and protected nothing. Without the mdl 1; header it still parses, does nothing and warns MDL-DEPR138, and mxcli fmt --upgrade deletes it; under mdl 1; it is an error. To keep a secret out of the model, leave DefaultValue empty and set the value per machine with mxcli constant set Module.Name <value>.

Parameters

OR MODIFY
Makes the statement idempotent. If the constant already exists, its definition is updated. Without this clause, creating a duplicate constant is an error.
module.name
The qualified name of the constant in the form Module.ConstantName. The module must already exist.
Type: data_type
The constant’s data type. One of: String, Integer, Long, Decimal, Boolean, DateTime.
DefaultValue: value
The default value for the constant. String values are single-quoted. Numeric values are bare. Boolean values are true or false.
ExposedToClient: true | false
Whether client-side code can read the value. Optional; false when left out.

Examples

String constant for API URL

CREATE CONSTANT MyModule.ApiBaseUrl ( Type: String, DefaultValue: 'https://api.example.com' );

Integer constant for configuration

CREATE CONSTANT MyModule.MaxRetries ( Type: Integer, DefaultValue: 3 );

Boolean feature flag

CREATE CONSTANT MyModule.EnableLogging ( Type: Boolean, DefaultValue: true );

Idempotent with OR MODIFY

CREATE OR MODIFY CONSTANT MyModule.ApiBaseUrl ( Type: String, DefaultValue: 'https://api.example.com/v2' );

Constant with an empty default

A DEFAULT is always required. For secrets, use an empty default and set the real value per configuration (see below):

CREATE CONSTANT MyModule.DatabasePassword ( Type: String, DefaultValue: '' );

Override constant per configuration

mdl 1;
-- Create the constant
CREATE CONSTANT MyModule.ApiBaseUrl ( Type: String, DefaultValue: 'https://api.example.com' );

-- Override in a specific runtime configuration
ALTER SETTINGS CONSTANT @MyModule.ApiBaseUrl VALUE 'https://staging.example.com' IN CONFIGURATION 'Staging';

Shared and private values

An override holds its value one of two ways:

  • Shared — stored in the model, so it travels with the project in version control and every developer gets it.
  • Private — stored on the developer’s own workstation and deliberately kept out of the repository. This is the answer for a development secret: the constant and the override are shared, the value is not.

MDL preserves that choice but never changes it — the shared/private decision belongs to the constant, and configurations just respect it:

StatementOn a private override
ALTER SETTINGS CONSTANT … VALUE …refused — setting a value would convert it to shared and publish a deliberately-local value into version control
ALTER SETTINGS DROP CONSTANT …allowed — removes the whole override, which is what was asked for
LIST CONSTANT VALUESreports (private) rather than a blank cell
DESCRIBE SETTINGSemits a comment, not a re-executable statement

To make a private value shared (or the reverse), change it in Studio Pro.

See Also

CREATE ENTITY, CREATE ENUMERATION

Microflow Statements

Statements for creating, altering and dropping microflows, nanoflows, and Java actions.

Microflows are the primary mechanism for implementing business logic in Mendix applications. They execute on the server and have access to the full range of activities including database operations, integrations, and security-sensitive actions.

Nanoflows share the same syntax as microflows but execute on the client (browser or native mobile). They have a restricted set of available activities.

StatementDescription
CREATE MICROFLOWCreate a microflow with activities and control flow
CREATE NANOFLOWCreate a client-side nanoflow
ALTER MICROFLOW / NANOFLOWPatch a stored flow in place: insert, replace or drop activities, or replace a loop’s body
CREATE JAVA ACTIONCreate a Java action stub with inline code
DROP MICROFLOW / NANOFLOWRemove a microflow or nanoflow

CREATE MICROFLOW

Synopsis

CREATE [ OR REPLACE ] MICROFLOW module.Name
    [ ( DECLARE $param : type [, ...] ) ]
    [ RETURN type ]
    [ FOLDER 'path' ]
BEGIN
    statements
END

Description

Creates a new microflow in the specified module. Microflows are the server-side logic building blocks in Mendix – they execute on the application server and have access to the full set of activities including database operations, external service calls, and security-sensitive actions.

If OR REPLACE is specified and a microflow with the same qualified name already exists, it is replaced. Otherwise, creating a microflow with an existing name is an error.

The microflow body consists of a sequence of statements enclosed in BEGIN ... END. Statements are executed in order, with control flow managed by IF, LOOP, WHILE, and RETURN constructs.

Microflow Parameters

Microflow parameters are declared in parentheses after the name. Each parameter has a name (prefixed with $), a colon, and a type. Parameters become variables available throughout the microflow body.

Return Type

If the microflow returns a value, specify RETURN type after the parameter list. The type can be any primitive type, an entity type, or a list type. Every execution path must end with a RETURN statement when a return type is declared.

Folder Placement

The optional FOLDER clause places the microflow in a subfolder within the module. Nested folders use / as separator. Missing folders are created automatically.

Activities

The following activities are available inside the microflow body.

Variable Declaration and Assignment

DECLARE $Var Type = value;
DECLARE $Entity Module.Entity;
DECLARE $List List of Module.Entity = empty;
SET $Var = expression;

Primitive types: String, Integer, Long, Decimal, Boolean, DateTime. Entity declarations do not use = empty. List declarations require = empty to initialize an empty list.

Object Operations

$Var = CREATE Module.Entity ( Attr1 = value1, Attr2 = value2 ) [ COMMIT [ WITHOUT EVENTS ] ] [ REFRESH ];
CHANGE $Entity ( Attr = value ) [ COMMIT [ WITHOUT EVENTS ] ] [ REFRESH ];
COMMIT $Entity [ WITHOUT EVENTS ] [ REFRESH ];
DELETE $Entity [ REFRESH ];
ROLLBACK $Entity [ REFRESH ];

CREATE instantiates a new object with initial attribute values. CHANGE modifies attributes on an existing object. COMMIT persists changes to the database – the before/after commit event handlers run unless you write WITHOUT EVENTS, matching Studio Pro’s default, and REFRESH updates client-side state. ROLLBACK reverts uncommitted changes to an object.

Every one of these modifiers is optional, and leaving one out always means Mendix’s own default – Commit: No and Refresh in client: No throughout, and With events: Yes on the standalone COMMIT. A bare statement therefore produces the same activity as dragging a fresh one onto the Studio Pro canvas.

Retrieval

-- Database retrieve with optional XPath constraint
RETRIEVE $Var FROM Module.Entity [ WHERE condition ] [ FIRST | [ LIMIT n ] [ OFFSET n ] ];

-- Retrieve by association
RETRIEVE $List FROM $Parent/Module.AssocName;

RETRIEVE ... FIRST returns a single entity. Without a range, or with LIMIT/OFFSET, it returns a list. A bare LIMIT 1 is a list of one (for a script file without the header, see Language Versions and Migration). Retrieve by association traverses an association from a known object.

List Changes

$List = CREATE LIST OF Module.Entity;
ADD $Item TO $List;
REMOVE $Item FROM $List;
CLEAR $List;
SET $List = $Other;

These are the four operations of a Change list activity: Add, Remove, Clear and Replace. SET on a list variable is Replace (stored as Set), not a Change variable activity, which Mendix allows only on primitive variables (CE7247). All four work in nanoflows too.

Calls

$Result = CALL MICROFLOW Module.Name ( Param = $value );
$Result = CALL NANOFLOW Module.Name ( Param = $value );
$Result = CALL JAVA ACTION Module.Name ( Param = value );

Call another microflow, nanoflow, or Java action. Parameters are passed by name. The result can be assigned to a variable when the callee has a return type. If no return value is needed, omit the $Result = prefix.

A flow may call itself: the target resolves to the flow the statement creates, with the return type it declares, so a recursive microflow or nanoflow is written in one statement — no stub created first.

A call may also name a flow the script creates further down, so mutually recursive flows can be written in either order. The call is stored by name; plain mxcli exec resolves it against every flow the script declares, because it rolls the whole run back if a statement fails. exec --continue-on-error, the REPL and -c cannot roll back and refuse such a call: there, create the callee first, or create a stub of it first and create or modify it after the caller.

UI Actions

SHOW PAGE Module.PageName ( Param = $value );
CLOSE PAGE;

SHOW PAGE opens a page, passing parameters by name. CLOSE PAGE closes the current page.

Validation and Logging

VALIDATION FEEDBACK $Entity/Attribute MESSAGE 'message';
LOG INFO | WARNING | ERROR [ NODE 'name' ] 'message';

VALIDATION FEEDBACK adds a validation error to a specific attribute on an object. LOG writes to the application log at the specified level, optionally tagged with a log node name.

Execute Database Query

$Result = EXECUTE DATABASE QUERY Module.Connector.QueryName;

Executes a Database Connector query. The three-part name identifies the connector module, connection, and query. Supports DYNAMIC, parameters, and CONNECTION override.

Control Flow

IF condition THEN
    statements
[ ELSE
    statements ]
END IF;

LOOP $Item IN $List BEGIN
    statements
END LOOP;

WHILE condition BEGIN
    statements
END WHILE;

RETURN $value;

IF branches on a boolean expression. LOOP iterates over each item in a list. WHILE loops while a condition holds true. RETURN ends execution and returns a value (required when the microflow declares a return type).

Error Handling

-- Suffix on any activity (except EXECUTE DATABASE QUERY)
activity ON ERROR CONTINUE;
activity ON ERROR ROLLBACK;
activity ON ERROR BEGIN
    handler_statements
END ERROR;

Error handling is attached as a suffix to an individual activity. ON ERROR CONTINUE suppresses the error and continues. ON ERROR ROLLBACK rolls back the current transaction. ON ERROR BEGIN ... END ERROR executes custom error-handling logic.

Annotations

Annotations are placed before an activity to control visual appearance in the microflow editor:

@position(x, y)          -- Canvas position
@start(x, y)             -- Canvas position of the start event (first statement only)
@caption 'text'          -- Custom caption
@color Green             -- Background color
@annotation 'text'       -- Visual note attached to next activity

@start positions the start event, which has no statement of its own, so it is written on the first statement — the one the start flows into. It is optional: omit it and the start is placed one spacing unit left of the first activity, on that activity’s centre line, and a later rewrite re-derives it so it follows the activities when they move.

A start that is not at that derived spot — one dragged somewhere in Studio Pro, or written with @start — is treated as placed on purpose. It survives a rewrite that does not mention it, and DESCRIBE emits an @start line for it so the description reproduces the flow exactly. An explicit @start overrides both.

Parameters

module.Name
The qualified name of the microflow (Module.MicroflowName). The module must already exist.
$param : type
A parameter declaration. The name must start with $. The type can be:
  • Primitive: String, Integer, Long, Decimal, Boolean, DateTime
  • Entity: Module.EntityName
  • List: List of Module.EntityName
  • Enumeration: Enumeration(Module.EnumName)
RETURN type
The return type of the microflow. Same type options as parameters.
FOLDER 'path'
Optional folder path within the module. Nested folders use / separator (e.g., 'Orders/Processing').

Examples

Simple microflow that creates and commits an object:

CREATE MICROFLOW Sales.ACT_CreateOrder
FOLDER 'Orders'
BEGIN
    DECLARE $Order Sales.Order;
    $Order = CREATE Sales.Order (
        OrderDate = [%CurrentDateTime%],
        Status = 'Draft'
    );
    COMMIT $Order;
    SHOW PAGE Sales.Order_Edit (Order = $Order);
    RETURN $Order;
END;

Microflow with parameters and conditional logic:

CREATE MICROFLOW Sales.ACT_ApproveOrder
    (DECLARE $Order: Sales.Order)
    RETURN Boolean
BEGIN
    IF $Order/Status = Sales.OrderStatus.Pending THEN
        CHANGE $Order (Status = Sales.OrderStatus.Approved);
        COMMIT $Order;
        LOG INFO NODE 'OrderProcessing' 'Order approved';
        RETURN true;
    ELSE
        VALIDATION FEEDBACK $Order/Status MESSAGE 'Only pending orders can be approved';
        RETURN false;
    END IF;
END;

Microflow with a loop over a retrieved list:

CREATE MICROFLOW Sales.ACT_DeactivateExpiredCustomers
    RETURN Integer
FOLDER 'Scheduled'
BEGIN
    DECLARE $Count Integer = 0;
    RETRIEVE $Customers FROM Sales.Customer
        WHERE [IsActive = true AND ExpiryDate < [%CurrentDateTime%]];
    LOOP $Customer IN $Customers BEGIN
        CHANGE $Customer (IsActive = false);
        COMMIT $Customer;
        SET $Count = $Count + 1;
    END LOOP;
    LOG INFO NODE 'Maintenance' 'Deactivated expired customers';
    RETURN $Count;
END;

Microflow with error handling:

CREATE MICROFLOW Integration.ACT_SyncData
BEGIN
    DECLARE $Result Integration.SyncResult;
    $Result = CREATE Integration.SyncResult (
        StartTime = [%CurrentDateTime%],
        Status = 'Running'
    );
    COMMIT $Result;

    $Result = CALL MICROFLOW Integration.SUB_FetchExternalData (
        SyncResult = $Result
    ) ON ERROR BEGIN
        CHANGE $Result (Status = 'Failed');
        COMMIT $Result;
        LOG ERROR NODE 'Integration' 'Sync failed';
    END ERROR;

    CHANGE $Result (
        Status = 'Completed',
        EndTime = [%CurrentDateTime%]
    );
    COMMIT $Result;
END;

Microflow calling a Java action:

CREATE MICROFLOW MyModule.ACT_GenerateReport
    (DECLARE $StartDate: DateTime, DECLARE $EndDate: DateTime)
    RETURN String
BEGIN
    DECLARE $Report String = '';
    $Report = CALL JAVA ACTION MyModule.JA_GenerateReport (
        StartDate = $StartDate,
        EndDate = $EndDate
    );
    RETURN $Report;
END;

Using OR REPLACE to update an existing microflow:

CREATE OR REPLACE MICROFLOW Sales.ACT_CreateOrder
FOLDER 'Orders'
BEGIN
    DECLARE $Order Sales.Order;
    $Order = CREATE Sales.Order (
        OrderDate = [%CurrentDateTime%],
        Status = 'Draft',
        CreatedBy = '[%CurrentUser%]'
    );
    COMMIT $Order;
    RETURN $Order;
END;

See Also

CREATE NANOFLOW, DROP MICROFLOW, CREATE JAVA ACTION, DESCRIBE MICROFLOW, GRANT EXECUTE ON MICROFLOW

CREATE NANOFLOW

Synopsis

CREATE [ OR REPLACE ] NANOFLOW module.Name
    [ ( DECLARE $param : type [, ...] ) ]
    [ RETURN type ]
    [ FOLDER 'path' ]
BEGIN
    statements
END

Description

Creates a new nanoflow in the specified module. Nanoflows share the same syntax as microflows but execute on the client (web browser or native mobile app) rather than on the server. This makes them suitable for offline-capable logic, instant UI feedback, and reducing server round-trips.

If OR REPLACE is specified and a nanoflow with the same qualified name already exists, it is replaced.

Restricted Activity Set

Because nanoflows run on the client, several server-side activities are not available:

  • No direct database retrieval with XPath (RETRIEVE ... FROM Entity WHERE ... over database)
  • No COMMIT with database transactions
  • No LOG statements (server log is inaccessible)
  • No EXECUTE DATABASE QUERY
  • No server-side Java action calls

Available activities in nanoflows include:

  • CREATE, CHANGE, DELETE (on client-side objects)
  • COMMIT (triggers synchronization when online)
  • RETRIEVE by association
  • CALL NANOFLOW (call other nanoflows)
  • CALL MICROFLOW (triggers a server round-trip)
  • SHOW PAGE, CLOSE PAGE
  • VALIDATION FEEDBACK
  • IF, LOOP, WHILE, RETURN
  • Variable declaration and assignment

Parameters, Return Type, and Folder

These work identically to CREATE MICROFLOW. See that page for full details on types, annotations, and folder placement.

Parameters

module.Name
The qualified name of the nanoflow (Module.NanoflowName). The module must already exist.
$param : type
A parameter declaration. Same types as microflow parameters: primitives, entities, lists, enumerations.
RETURN type
The return type of the nanoflow.
FOLDER 'path'
Optional folder path within the module.

Examples

Nanoflow that validates an object on the client:

CREATE NANOFLOW Sales.NAV_ValidateOrder
    (DECLARE $Order: Sales.Order)
    RETURN Boolean
BEGIN
    IF $Order/CustomerName = empty THEN
        VALIDATION FEEDBACK $Order/CustomerName MESSAGE 'Customer name is required';
        RETURN false;
    END IF;
    IF $Order/Amount <= 0 THEN
        VALIDATION FEEDBACK $Order/Amount MESSAGE 'Amount must be positive';
        RETURN false;
    END IF;
    RETURN true;
END;

Nanoflow that toggles a UI state:

CREATE NANOFLOW MyModule.NAV_ToggleDetail
    (DECLARE $Helper: MyModule.UIHelper)
BEGIN
    IF $Helper/ShowDetail = true THEN
        CHANGE $Helper (ShowDetail = false);
    ELSE
        CHANGE $Helper (ShowDetail = true);
    END IF;
END;

Nanoflow calling a microflow for server-side work:

CREATE NANOFLOW Sales.NAV_SubmitOrder
    (DECLARE $Order: Sales.Order)
BEGIN
    CHANGE $Order (Status = 'Submitted');
    $Result = CALL MICROFLOW Sales.ACT_ProcessOrder (Order = $Order);
    SHOW PAGE Sales.Order_Confirmation (Order = $Order);
END;

See Also

CREATE MICROFLOW, DROP MICROFLOW, GRANT EXECUTE ON NANOFLOW

ALTER MICROFLOW / NANOFLOW

Synopsis

mdl 1;
ALTER MICROFLOW module.Name {
  INSERT AFTER | BEFORE <target> BEGIN <statements> END;
  REPLACE <target> WITH BEGIN <statements> END;
  REPLACE BODY OF <loop> WITH BEGIN <statements> END;
  DROP <target>;
};

ALTER NANOFLOW module.Name { … };

Description

Patches a stored microflow or nanoflow in place: the operations are spliced into the stored graph rather than rebuilding it. Only the new activities, the flows rewired around them and the nodes moved to make room change; every other element keeps its element ID ($ID), position and curve, so Studio Pro shows the change and nothing else.

A target addresses one stored activity by content, as DESCRIBE MICROFLOW module.Name WITH HANDLES prints it above each activity: $Var for the activity that outputs $Var, 'Caption' for a decision or an activity with a custom caption, or a statement pattern in which * matches any run of tokens — followed by @n when several match. Every target is resolved against the flow as stored before any operation runs.

A fragment is written exactly as the same statements are in CREATE MICROFLOW, between BEGIN and END.

Editing an activity

REPLACE by one activity of the same kind — the same action, with a filter’s or find’s expression, a retrieve’s WHERE, a member’s value or a call’s argument changed — edits the stored activity rather than putting a new one in its place. The activity keeps its element ID, its place and its size, and so do its action and every element under it that pairs up: a member of a change or create by its attribute or association, an argument by its parameter, anything else by its property and type. A member taken out goes with its ID, which no other member inherits; a part of another type (a FIND where a FILTER was) is new. Studio Pro, which compares and merges by element ID, then shows the activity as changed rather than one removed and another added. An activity replaced by another kind of activity, or by several, is new.

Inside a loop body

Inside a loop body, plain activities are inserted, replaced and dropped the same way — the body’s first and last activity included. The new activities are laid out among the body’s nodes; when the body no longer fits, the loop box grows on its far side, and what is drawn past the loop moves along. The loop’s iterator is in scope.

What would change the body’s shape — a decision, a nested loop, a RETURN, BREAK or CONTINUE, a RAISE ERROR or an activity with a custom error handler put in, changed or taken out — is refused there, and the refusal names REPLACE BODY OF.

REPLACE BODY OF

REPLACE BODY OF <loop> keeps the stored loop itself — its element ID, its position, its iterator and list, the flows into and out of it and the notes on it — and replaces only what is inside it. The new body is laid out as CREATE MICROFLOW lays out a loop body; the loop box keeps its size when the new body fits it, and grows on its far side when it does not.

<loop> is the loop’s handle: loop $It in $Items, or while $N < 3 for a while loop, at any depth of nesting. The new body is scope-checked like any fragment: it may read the iterator, what it declares itself and what is declared before the loop, and it may not declare a variable the flow already has outside the loop.

REPLACE <loop handle> WITH BEGIN LOOP … END LOOP; END; also works, and rebuilds the loop as well — its element ID and position included.

Decisions

DROP and REPLACE take a decision out whole when it has the shape INSERT produces: two paths of plain activities, or none, that rejoin at one merge. The flow into the decision is joined to the node after its merge (or to the replacing fragment), and the decision, the activities on its paths and the merge go; every other element keeps its element ID. A decision is addressed by its handle — its caption, '$Order/Total > 100'. Any other decision — a path that returns or raises an error, a decision or loop on a path, an activity with an error handler, a path from elsewhere joining it — is refused with the part of its shape that is out of reach.

CREATE OR MODIFY

CREATE OR MODIFY MICROFLOW of a flow that exists derives the same operations from the difference between the script and the stored flow. A plain activity added, removed, reordered or changed inside a loop body is spliced; under mdl 1; a change the splice cannot make is refused with the operation that makes it — for a decision inside a loop body, REPLACE BODY OF that loop. Re-running the script that created a flow after another script spliced a decision into it takes that decision out again, when it has the shape above.

A statement changed where it stands edits its stored activity, as above, and so does each of several adjacent statements changed in their own places. The report says which: Modified microflow: M.F (spliced: 1 edited in place) for a node that kept its element ID — an activity of the same kind, a decision’s condition, a return’s value — and replaced with new element IDs for one that did not.

Examples

Insert, replace and drop at top level:

mdl 1;
ALTER MICROFLOW FeedbackModule.VAL_Feedback {
  INSERT AFTER $IsValidEmail BEGIN LOG INFO NODE 'Feedback' 'Email checked'; END;
  REPLACE COMMIT $Order WITH BEGIN COMMIT $Order WITH EVENTS; END;
  DROP LOG DEBUG NODE 'Feedback' *;
};

Add an activity inside a loop body:

mdl 1;
ALTER MICROFLOW Shop.ACT_ProcessLines {
  INSERT AFTER COMMIT $Line BEGIN LOG INFO NODE 'Shop' 'line saved'; END;
};

Put a decision inside a loop body, keeping the loop:

mdl 1;
ALTER MICROFLOW Shop.ACT_ProcessLines {
  REPLACE BODY OF LOOP $Line IN $Lines WITH BEGIN
    IF $Line/Quantity > 0 THEN
      CHANGE $Line (Processed = true);
    END IF;
    COMMIT $Line;
  END;
};

Take out a decision another script put in:

mdl 1;
ALTER MICROFLOW Shop.ACT_Order {
  DROP '$Order/Total > 100';
};

See Also

CREATE MICROFLOW, DESCRIBE MICROFLOW

DROP MICROFLOW / NANOFLOW

Synopsis

mdl 1;
DROP MICROFLOW module.Name;

DROP NANOFLOW module.Name;

Description

Removes a microflow or nanoflow from the project. The microflow or nanoflow must exist; otherwise an error is raised.

Dropping a microflow or nanoflow does not automatically update references to it from other microflows, pages, navigation, or security rules. Use LIST IMPACT OF module.Name before dropping to identify all references that will break.

Parameters

module.Name
The qualified name of the microflow or nanoflow to drop (Module.MicroflowName or Module.NanoflowName).

Examples

Drop a microflow:

DROP MICROFLOW Sales.ACT_CreateOrder;

Drop a nanoflow:

DROP NANOFLOW Sales.NAV_ValidateOrder;

Check impact before dropping:

mdl 1;
-- See what references this microflow
LIST IMPACT OF Sales.ACT_CreateOrder;

-- Then drop if safe
DROP MICROFLOW Sales.ACT_CreateOrder;

See Also

CREATE MICROFLOW, CREATE NANOFLOW, LIST IMPACT

CREATE JAVA ACTION

Synopsis

CREATE [ OR MODIFY ] JAVA ACTION module.Name [ < T [, ...] > ] [ FOLDER 'folder_path' ] ( parameters )
    RETURNS type [ AS $return_name ]
    [ EXPOSED AS 'caption' IN 'category' ]
    AS $$ java_code $$
    [ EXTRA CODE $$ helper_code $$ ]

DROP JAVA ACTION module.Name;

Description

Creates a Java action with inline Java code. Java actions allow custom server-side logic written in Java to be called from microflows.

The action parameters, return type, and Java body are all specified inline. When the action is created, the corresponding Java source file is generated with the parameter boilerplate and the provided code body.

The AS $$ ... $$ body is mandatory. Unlike microflows, Java actions require a code body in every CREATE statement. If the body is omitted the parser reports no viable alternative at input '...'. Use a minimal stub body if the implementation is not yet written: AS $$ return false; $$.

If OR MODIFY is specified and the Java action already exists, its parameters, return type, exposed-as settings, and Java body are updated in place. The UUID and any existing Java source file path are preserved.

Type Parameters

Java actions support generic entity handling through type parameters. A parameter declared as ENTITY <pEntity> creates a type parameter selector – the caller chooses which entity type to use. Other parameters declared as bare pEntity (without ENTITY) receive instances of the selected entity type.

A type parameter that no ENTITY <T> parameter names – one bound by the object the caller passes in – is declared in the header after the action name: module.Name<T>. The header also fixes the order of the type parameters when it differs from the order the ENTITY <T> parameters would give. An unqualified type that is neither a declared type parameter nor Microflow/Nanoflow is refused (MDL-JA01): it would be written as an entity with no module, which Mendix reports as CE1613.

Parameter Descriptions and the Return Value Name

A parameter’s description is a /** ... */ doc comment written before it – the same form an entity attribute’s documentation takes. RETURNS type AS $Name names the return value: the default name of the variable a microflow call stores the result in, as RETURNS type AS $Name does on a microflow. Both are what Studio Pro shows when a modeler drags the action into a microflow, and what DESCRIBE JAVA ACTION prints.

On CREATE OR MODIFY, leaving either out keeps what is stored; an empty /** */ clears a description. A new action gets no descriptions and the return value name ReturnValueName, as in Studio Pro.

The Java Source File

The statement writes javasource/<module>/actions/<Name>.java the way mxbuild generates it, byte for byte, so the next build leaves it unchanged.

The body is only in this file, not in the model, so commit javasource/ together with the .mpr and mprcontents/. A commit of the model alone gives a clone an action with no logic. mxcli exec names each file it writes there on its own line, for example Wrote javasource/sales/actions/JA_Ping.java (Java action body; commit it with the model), and ends the run with Files changed outside the model: N (…). A rewrite that leaves the file’s content as it was is not reported. RENAME JAVA ACTION moves the file and DROP JAVA ACTION removes it; both are reported the same way. The action’s documentation – the /** ... */ before CREATE – is the class javadoc above public class; a rewrite without one keeps the stored documentation, in the model and in the file. (Before ako/mxcli#1700 the file had no javadoc, so every rewrite removed the one a build had written and the next build put it back.)

Studio Pro keeps three parts of the file when it regenerates it, and the statement states all three: the code between BEGIN USER CODE and END USER CODE is the AS $$ ... $$ body; an import line in the body goes to the file’s import list; and EXTRA CODE $$ ... $$ is the section between BEGIN EXTRA CODE and END EXTRA CODE – helper methods, constants and nested classes the body calls. On CREATE OR MODIFY an omitted EXTRA CODE keeps the stored section, as an omitted EXPOSED AS keeps the toolbox entry; EXTRA CODE $$ $$ clears it. DESCRIBE JAVA ACTION prints the imports a developer added and the section, so executing its output on another project rebuilds the same file (ako/mxcli#1702).

Exposed Actions

The optional EXPOSED AS clause makes the action visible in the Studio Pro toolbox under the specified category, allowing modelers to use it directly in microflows.

Parameters

module.Name
The qualified name of the Java action.
< T [, ...] >
Optional. Declares type parameters, in order. Needed only for a type parameter that no ENTITY <T> parameter declares; a name that is a keyword or a primitive is quoted (<"Entity">).
FOLDER 'folder_path'
Optional. Places the document in the named module folder, creating missing folders in the path. On CREATE OR MODIFY this moves an existing document; omitting the clause leaves placement alone rather than returning the document to the module root. See MOVE.
parameters
Comma-separated parameter declarations. Each parameter has an optional /** description */, a name, colon, and type. Supported types:
  • Primitives: String, Integer, Long, Decimal, Boolean, DateTime
  • Entity: Module.EntityName
  • List: List of Module.EntityName
  • Enumeration: ENUM Module.EnumName or Enumeration(Module.EnumName)
  • String template: StringTemplate(Sql), StringTemplate(Oql)
  • Type parameter declaration: ENTITY <pEntity> (declares the type parameter)
  • Type parameter reference: bare pEntity (uses the declared type parameter)
  • Add NOT NULL after the type to mark a parameter as required.
RETURNS type
The return type. Same type options as parameters.
AS $return_name
Optional. The return value’s name. Omitted, the stored name is kept (ReturnValueName on a new action).
EXPOSED AS 'caption' IN 'category'
Optional. Makes the action visible in the toolbox with the given caption and category.
AS $$ java_code $$
The Java code body, enclosed in $$ delimiters. Mandatory — the body cannot be omitted. An import line in it goes to the file’s import list.
EXTRA CODE $$ helper_code $$
Optional. The file’s EXTRA CODE section, beside executeAction(). Omitted, the stored section is kept; empty, it is cleared.
OR MODIFY
Makes the statement idempotent. If the Java action already exists, updates its parameters, return type, exposed-as settings, and body. Without this clause, creating a duplicate Java action is an error.

Examples

Simple Java action returning a string:

CREATE JAVA ACTION MyModule.JA_FormatCurrency (
    Amount: Decimal NOT NULL,
    CurrencyCode: String NOT NULL
) RETURNS String
AS $$
    java.text.NumberFormat formatter = java.text.NumberFormat.getCurrencyInstance();
    formatter.setCurrency(java.util.Currency.getInstance(CurrencyCode));
    return formatter.format(Amount);
$$;

Java action with parameter descriptions and a named return value:

/** Converts text to upper case. */
CREATE JAVA ACTION MyModule.JA_ToUpper (
    /** The text to convert. */
    Input: String NOT NULL
) RETURNS String AS $UpperText
AS $$
    return Input.toUpperCase();
$$;

Java action with a helper in the EXTRA CODE section, and the import it needs:

CREATE JAVA ACTION MyModule.JA_Twice (
    N: Integer NOT NULL
) RETURNS Integer
AS $$
    import java.util.Objects;

    return twice(Objects.requireNonNull(N));
$$
EXTRA CODE $$
private static long twice(long n) {
	return n * 2L;
}
$$;

Java action with type parameters for generic entity handling:

CREATE JAVA ACTION MyModule.JA_Validate (
    EntityType: ENTITY <pEntity> NOT NULL,
    InputObject: pEntity NOT NULL
) RETURNS Boolean
EXPOSED AS 'Validate Entity' IN 'Validation'
AS $$
    return InputObject != null;
$$;

Java action with a type parameter that no entity selector declares – the caller’s object binds it:

CREATE JAVA ACTION MyModule.JA_Describe<pEntity> (
    InputObject: pEntity NOT NULL
) RETURNS String
AS $$
    return InputObject.toString();
$$;

Java action with a list parameter:

CREATE JAVA ACTION MyModule.JA_ExportToCsv (
    Records: List of MyModule.Customer NOT NULL,
    FilePath: String NOT NULL
) RETURNS Boolean
AS $$
    // Export logic here
    return true;
$$;

Drop a Java action:

DROP JAVA ACTION MyModule.JA_FormatCurrency;

Idempotent upsert with OR MODIFY

mdl 1;
-- Initial creation
CREATE JAVA ACTION MyModule.JA_FormatCurrency (
    Amount: Decimal NOT NULL,
    CurrencyCode: String NOT NULL
) RETURNS String
AS $$
    return Amount.toString();
$$;

-- Later: update to add proper formatting (UUID preserved)
CREATE OR MODIFY JAVA ACTION MyModule.JA_FormatCurrency (
    Amount: Decimal NOT NULL,
    CurrencyCode: String NOT NULL
) RETURNS String
AS $$
    java.text.NumberFormat formatter = java.text.NumberFormat.getCurrencyInstance();
    formatter.setCurrency(java.util.Currency.getInstance(CurrencyCode));
    return formatter.format(Amount);
$$;

Minimal stub body

When a Java action needs to exist in the project but the implementation is not yet written:

CREATE JAVA ACTION MyModule.JA_Placeholder (
    Input: String NOT NULL
) RETURNS Boolean
AS $$ return false; $$;

See Also

CREATE MICROFLOW, LIST JAVA ACTIONS

CREATE JAVASCRIPT ACTION

Synopsis

CREATE [ OR MODIFY ] JAVASCRIPT ACTION module.Name [ FOLDER 'folder_path' ] ( parameters )
    RETURNS type [ AS $return_name ]
    [ EXPOSED AS 'caption' IN 'category' ]
    [ PLATFORM Web | Native | Hybrid | All ]
    AS $$ javascript_code $$

DROP JAVASCRIPT ACTION module.Name;

Description

Creates a JavaScript action with inline JavaScript code. JavaScript actions hold custom client-side logic and are callable from nanoflows.

Two things are written: the model unit (JavaScriptActions$JavaScriptAction), and the source file javascriptsource/<Module>/actions/<Name>.js. The .js file is generated with the JSDoc header, the exported async function, and the supplied body placed between the BEGIN USER CODE / END USER CODE markers.

The body is only in the .js file, not in the model, so commit javascriptsource/ together with the .mpr and mprcontents/. A commit of the model alone gives a clone an action with no logic. mxcli exec names each file it writes there on its own line, for example Wrote javascriptsource/sales/actions/JS_Ping.js (JavaScript action body; commit it with the model), and ends the run with Files changed outside the model: N (…). A rewrite that leaves the file’s content as it was is not reported.

The AS $$ ... $$ body is mandatory. Like Java actions, every CREATE needs a code body; omitting it reports no viable alternative at input '...'. Use a stub such as AS $$ return Promise.resolve(false); $$ when the implementation is not yet written.

If OR MODIFY is specified and the action already exists, its parameters, return type, exposed-as settings, platform, and body are updated in place; the UUID is preserved.

Parameter names in the body

The model keeps a parameter’s name as declared, but the generated .js file uses the name Studio Pro and mxbuild generate: the first character lower-cased, nothing else. The body is written against that name. ReadOnly is readOnly, Url is url, URL is uRL (not url), MyID is myID, _Lead stays _Lead. mxcli writes the signature this way and does not rewrite the body, so a body that uses the declared spelling (ReadOnly) throws a ReferenceError when the action runs. DESCRIBE still prints the declared names.

Parameter Descriptions and the Return Value Name

As for a Java action, a parameter’s description is a /** ... */ doc comment before it, and RETURNS type AS $Name names the return value. Leaving either out on CREATE OR MODIFY keeps what is stored. See CREATE JAVA ACTION.

Platform

The optional PLATFORM clause restricts where the action runs: Web, Native, Hybrid, or All. When omitted it defaults to Web.

Exposed Actions

The optional EXPOSED AS clause makes the action visible in the Studio Pro toolbox under the given category.

Parameters

module.Name
The qualified name of the JavaScript action.
FOLDER 'folder_path'
Optional. Places the document in the named module folder, creating missing folders in the path. On CREATE OR MODIFY this moves an existing document; omitting the clause leaves placement alone rather than returning the document to the module root. See MOVE.
parameters
Comma-separated parameter declarations (name, colon, type). Supported types:
  • Primitives: String, Integer, Long, Decimal, Boolean, DateTime
  • Entity: Module.EntityName
  • List: List of Module.EntityName
  • Enumeration: ENUM Module.EnumName or Enumeration(Module.EnumName)
  • Type parameter declaration: ENTITY <pEntity>; reference: bare pEntity
  • Add NOT NULL after the type to mark a parameter as required.
RETURNS type
The return type. Same type options as parameters.
EXPOSED AS 'caption' IN 'category'
Optional. Makes the action visible in the toolbox.
PLATFORM Web | Native | Hybrid | All
Optional. Target client platform; defaults to Web.
AS $$ javascript_code $$
The JavaScript body, enclosed in $$ delimiters. Mandatory. The code becomes the body of an exported async function, so it typically returns a Promise.
OR MODIFY
Makes the statement idempotent — updates an existing action in place (UUID preserved) instead of erroring on a duplicate.

Examples

Simple action (defaults to the Web platform):

CREATE JAVASCRIPT ACTION MyModule.JSA_IsOnline () RETURNS Boolean
AS $$
    return Promise.resolve(navigator.onLine);
$$;

Action with parameters:

CREATE JAVASCRIPT ACTION MyModule.JSA_Add (
    A: Integer NOT NULL,
    B: Integer NOT NULL
) RETURNS Integer
AS $$
    return Promise.resolve(a + b);
$$;

Exposed, native-only action:

CREATE JAVASCRIPT ACTION MyModule.JSA_ShowToast (
    Message: String NOT NULL,
    Duration: Integer
) RETURNS Boolean
EXPOSED AS 'Show Toast' IN 'UI'
PLATFORM Native
AS $$
    console.log(message);
    return Promise.resolve(true);
$$;

Idempotent upsert with OR MODIFY (UUID preserved):

CREATE OR MODIFY JAVASCRIPT ACTION MyModule.JSA_Add (
    A: Integer NOT NULL,
    B: Integer NOT NULL,
    C: Integer
) RETURNS Integer
AS $$
    return Promise.resolve(a + b + (c || 0));
$$;

Drop a JavaScript action (removes the unit and the .js source file):

DROP JAVASCRIPT ACTION MyModule.JSA_IsOnline;

See Also

CREATE JAVA ACTION, CREATE MICROFLOW

Page Statements

Statements for creating, modifying, and dropping pages, snippets, and layouts.

Pages define the user interface of a Mendix application. Each page has a layout, a title, optional parameters, and a widget tree that describes the UI structure. Snippets are reusable widget fragments that can be embedded in pages.

StatementDescription
CREATE PAGECreate a page with a widget tree
CREATE SNIPPETCreate a reusable widget fragment
ALTER PAGE / ALTER SNIPPETModify an existing page or snippet in-place
DROP PAGE / SNIPPETRemove a page or snippet
CREATE LAYOUTCreate a layout — the frame a page renders inside
ALTER LAYOUTEdit a layout in place; move pages onto a different one

CREATE PAGE

Synopsis

CREATE [ OR REPLACE ] PAGE module.Name [ FOLDER 'path' ]
(
    [ Params: ( $param : Module.Entity | Type [optional] [, ...] ), ]
    Title: 'title',
    Layout: Module.LayoutName
    [, Variables: ( $name : type = <expression> [, ...] ) ]
)
{
    widget_tree
}

Description

Creates a new page in the specified module. A page defines a user interface screen with a title, a layout, optional parameters, and a hierarchical widget tree.

If OR REPLACE is specified and a page with the same qualified name already exists, it is replaced. Otherwise, creating a page with an existing name is an error.

A replace (OR REPLACE or OR MODIFY) is refused when the stored page holds a widget MDL cannot author — a classic data grid, a reference selector, a drop-down, a reference set selector — that the statement does not declare by name, because the rebuilt page would not contain it. DESCRIBE PAGE marks each such widget with a -- NOT re-executable comment, and the error lists them. Change the page with ALTER PAGE, which keeps them, or remove them on purpose first (ALTER PAGE M.P { DROP grid1; };). The same applies to snippets and layouts, and mxcli check -p reports it before anything is written.

Page Properties

The page declaration block ( ... ) contains comma-separated key-value properties:

  • Params – Page parameters. Each parameter has a $-prefixed name and a type — either an entity (Module.Entity) or a primitive (String, Integer, Decimal, Boolean, DateTime). Parameters are passed when the page is opened (e.g., from a microflow SHOW PAGE or from a data view). Primitive parameters require Mendix 11.6+. A parameter is required unless it says optional ($Sale: Shop.Sale optional, Studio Pro’s unticked Required, Mendix 11.5+): an optional parameter lets the page be opened with no argument, from a menu item for example, where a required one is CE1571. describe page prints it, and a snippet parameter cannot be optional. A parameter cannot be named with a platform-reserved word — Context, Type, Id, Owner, Object, CurrentUser or a Java keyword, in any case and even quoted: Mendix reports CE7247 “The name ‘Context’ is a reserved word.”, and mxcli check refuses it (MDL-PARAM01). The same holds for snippet, Java action and JavaScript action parameters.
  • Title – The page title displayed in the browser tab or page header.
  • Layout – The layout the page is based on. Must be a qualified name referring to an existing layout (e.g., Atlas_Core.Atlas_Default, Atlas_Core.PopupLayout).
  • Folder – Optional folder path within the module. Nested folders use / separator.
  • Variables – Optional page-level variables with default expressions. Variables can be used for conditional visibility and dynamic behavior.

Widget Tree

The widget tree inside { ... } defines the page content. Widgets are nested hierarchically. Each widget has:

  • A type keyword (e.g., DATAVIEW, TEXTBOX, ACTIONBUTTON)
  • A name (unique within the page, used for ALTER PAGE references)
  • Properties in parentheses (Key: value, ...)
  • Optional children in braces { ... }

Widget Types

Layout Widgets

WidgetDescriptionKey Properties
LAYOUTGRIDResponsive grid containerClass, Style, Width (FullWidth default, FixedWidth)
ROWGrid rowClass
COLUMNGrid columnClass
CONTAINERGeneric div containerClass, Style, DesignProperties
CUSTOMCONTAINERCustom container widgetClass, Style

Data Widgets

WidgetDescriptionKey Properties
DATAVIEWDisplays/edits a single objectDataSource
LISTVIEWRenders a list of objectsDataSource, PageSize
DATAGRIDTabular data display with columnsDataSource, PageSize, Pagination
GALLERYCard-based list displayDataSource, DesktopColumns, TabletColumns, PhoneColumns

Input Widgets

WidgetDescriptionKey Properties
TEXTBOXSingle-line text inputLabel, Attribute, Password, OnChange, OnEnter, OnLeave, OnEnterKeyPress (an action run when the user presses Enter in the box; the stored name OnEnterKeyPressAction is accepted too), SubmitBehaviour (OnEndEditing default, WhileEditing), SubmitOnInputDelay (ms, default 300), KeyboardType (the keyboard a native app opens: Default, NumberPad, DecimalPad, EmailAddress, PhonePad, URL)
TEXTAREAMulti-line text inputLabel, Attribute, OnChange, OnEnter, OnLeave, SubmitBehaviour, SubmitOnInputDelay
CHECKBOXBoolean toggleLabel, Attribute, OnChange, OnEnter, OnLeave
RADIOBUTTONSRadio button groupLabel, Attribute, OnChange, OnEnter, OnLeave
DATEPICKERDate/time selectorLabel, Attribute, OnChange, OnEnter, OnLeave
COMBOBOXDropdown selectorLabel, Attribute; association mode adds DataSource, CaptionAttribute; database mode optionsSourceDatabaseDataSource, CaptionAttribute, optionsSourceDatabaseValueAttribute; custom content as the mode’s body block (optionssourceassociationcustomcontent { … }, optionssourcedatabasecustomcontent { … })
FILEMANAGERFile upload/download, inside a data view on a System.FileDocument specializationLabel, Type (Upload, Download, Both default), MaxFileSize (MB, default 5), AllowedExtensions, ShowFileInBrowser

Display Widgets

WidgetDescriptionKey Properties
DYNAMICTEXTDisplay-only text bound to an attributeAttribute
LABELStudio Pro’s Label widget (Forms$Label): a fixed, translatable caption. describe emits it for existing labels; prefer DYNAMICTEXT for new textContent
IMAGEGeneric imageWidth, Height
STATICIMAGEFixed image from project resourcesWidth, Height
DYNAMICIMAGEImage from an entity attributeWidth, Height

Action Widgets

WidgetDescriptionKey Properties
ACTIONBUTTONButton that triggers an actionCaption, Action, ButtonStyle, Icon
LINKBUTTONHyperlink-styled actionCaption, Action, Icon

Structure Widgets

WidgetDescriptionKey Properties
HEADERPage header area–
FOOTERPage footer area (typically for buttons)–
CONTROLBARControl bar for data grids–
SNIPPETCALLEmbeds a snippetSnippet: Module.SnippetName
NAVIGATIONLISTNavigation sidebar list–

DataSource Types

The DataSource property determines how a data widget obtains its data:

DataSourceSyntaxDescription
ParameterDataSource: $ParamNameBinds to a page parameter or enclosing data view
DatabaseDataSource: DATABASE Module.EntityRetrieves from database
SelectionDataSource: SELECTION widgetNameListens to selection on another widget
MicroflowDataSource: MICROFLOW Module.MFNameCalls a microflow for data. Takes with (FormValidations: All|Widget|None) like a microflow action (default All); no other setting

Action Types

The Action property determines what happens when the widget is clicked. It is not a button-only property: a CONTAINER is clickable too and takes the same values, including the argument list.

ActionSyntaxDescription
SaveAction: save changesCommits and closes
CancelAction: cancel changesRolls back and closes
MicroflowAction: MICROFLOW Module.Name(Param = val)Calls a microflow
NanoflowAction: NANOFLOW Module.Name(Param = val)Calls a nanoflow
PageAction: PAGE Module.PageNameOpens a page
CloseAction: CLOSE PAGECloses the current page
DeleteAction: DELETEDeletes the context object
NothingAction: NOTHINGDeliberately no action — a decorative button, a card that is not clickable

The set is closed. Anything else in an action slot is an error (MDL-WIDGET28), and that includes a real action keyword missing its argument — Action: OPEN LINK with no URL, Action: SHOW PAGE with no page. Such a widget used to be written with no action at all: it rendered, carried its caption, and did nothing, while mxcli check, exec and mxbuild all reported success, because a no-action widget is perfectly legal Mendix. Write NOTHING when a control is genuinely meant to be inert, so that a dead one always means a mistake.

The same values serve OnClick: (an alias of Action:), OnChange: and a text box’s OnEnterKeyPress: — Studio Pro’s On enter key press, which runs when the user presses Enter in the box (a search field that searches on Enter):

TEXTBOX tbSearch (Label: 'Search', Attribute: SearchText,
  OnEnterKeyPress: CALL MICROFLOW Module.ACT_Search(Query = $currentObject))

Only a text box has that action — Mendix stores it on no other widget, a text area included — so on any other widget the key is refused with that reason.

Every input widget (text box, text area, date picker, check box, radio buttons, drop-down) also has Studio Pro’s On enter and On leave — focus moving into and out of the widget, not the Enter key — spelled OnEnter: and OnLeave:, with the same values. The stored names OnEnterAction:, OnLeaveAction: and OnEnterKeyPressAction: are accepted as the same properties; DESCRIBE prints the short ones.

A flow or page that takes no parameters may be called with an empty argument list, as a flow body writes it: Action: CALL MICROFLOW Module.Name() stores exactly what Action: CALL MICROFLOW Module.Name stores, and DESCRIBE writes it without the brackets. The same holds for a microflow or nanoflow data source and for USE FRAGMENT Name().

Action settings

Any action but NOTHING takes a with ( … ) list of the settings Studio Pro shows under the event:

SettingApplies toDefault
DisabledDuringExecution: true|falseevery actiontrue
ProgressBar: None|NonBlocking|Blockingmicroflow and nanoflow callsNone
ProgressMessage: 'text'microflow and nanoflow callsnone
Confirmation: 'question'microflow and nanoflow callsno confirmation
ProceedCaption: 'text', CancelCaption: 'text'a confirmation’s buttonsProceed, Cancel
Asynchronous: true|falsemicroflow callsfalse
FormValidations: All|Widget|Nonemicroflow callsAll
ACTIONBUTTON btnDelete (Caption: 'Delete',
  Action: CALL MICROFLOW Shop.ACT_Order_Delete(Order = $currentObject) with (
    ProgressBar: Blocking, ProgressMessage: 'Deleting…',
    Confirmation: 'Delete this order?', ProceedCaption: 'Delete', CancelCaption: 'Keep'))

A setting left out keeps its default, and DESCRIBE prints only the settings that differ from it. An unknown key, or a setting the action does not have, is an error. For CREATE OBJECT … THEN SHOW PAGE … the list goes at the end and belongs to the create action.

Return value mappings

A microflow or nanoflow call can map what the flow returns back onto the page — Studio Pro’s Return value mappings (Mendix 11.5+). The returns ( … ) list goes after the arguments and before with ( … ), one target = source per row:

DATAVIEW dvVehicle (DataSource: $Vehicle) {
  ACTIONBUTTON btnRefresh (Caption: 'Refresh',
    Action: CALL MICROFLOW Shop.ACT_Vehicle_Get returns (
      Brand = $ActionReturnValue/Brand,     -- an attribute of the returned object
      $stringResult = $ActionReturnValue,   -- the return value itself
      Brand = 'Demo brand'))                -- any expression
}
TargetMeansNeeds
Attr (bare)an attribute of the enclosing data container’s objectMendix 11.15 — mx 11.13 and 11.14 report CE7006 on it
$namea page variable (Variables: ( … ))Mendix 11.5

The source is $ActionReturnValue, $ActionReturnValue/Attr (the flow must return an object that has Attr), or any expression. DESCRIBE prints the stored mappings, and re-running a statement without the list removes them — the statement is the authority. A mapping DESCRIBE cannot spell (a path over an association) is printed as a comment under the widget, saying a re-run drops it.

A microflow or nanoflow action is a call: every parameter the flow declares needs an argument, or Mendix rejects the page with CE1571. An enclosing data container of the parameter’s type supplies it without one — but a data grid’s control bar does not, because it is not row-scoped. Pass the grid’s selection there, addressed by the widget’s own name:

DATAGRID dgOrders (DataSource: DATABASE FROM Sales.Order, Selection: Single) {
  COLUMN (Attribute: Number, Caption: 'Order #')
  CONTROLBAR {
    CONTAINER cShip (Class: 'command',
      Action: CALL NANOFLOW Sales.ACT_Ship(Order = $dgOrders)) {
      ACTIONBUTTON btnShip (Caption: 'Ship')
    }
  }
}

A SHOW PAGE in the control bar takes the selection the same way — Action: SHOW PAGE Sales.Order_Detail(Order = $dgOrders) opens the page with the selected row, and the button is disabled until a row is selected. In a grid with Selection, a SHOW PAGE in a column’s custom content may pass the grid’s selection by name as well, which is what Studio Pro stores there; $currentObject is the row.

A control bar takes its object from above its grid, but sits inside the grid, so the data view around the grid is an outer one there: $dvOrder from the control bar of a grid in data view dvOrder is valid.

mxcli check -p <app.mpr> reports a missing argument before mxbuild does.

ButtonStyle Values

Primary, Default, Success, Danger, Warning, Info.

DataGrid Columns

Inside a DATAGRID, child widgets define columns. Each column widget specifies:

PropertyValuesDescription
Attributeattribute nameThe entity attribute to display
CaptionstringColumn header (defaults to attribute name)
Alignmentleft, center, rightText alignment
WrapTexttrue, falseWhether text wraps
Sortabletrue, falseWhether column is sortable
Resizabletrue, falseWhether column is resizable
Draggabletrue, falseWhether column is draggable
Hidableyes, hidden, noColumn visibility control
ColumnWidthautoFill, autoFit, manualWidth mode
Sizeinteger (px)Manual width in pixels
Visibleexpression stringConditional visibility
Tooltiptext stringColumn tooltip

Common Widget Properties

These properties are available on most widget types:

PropertyDescriptionExample
ClassCSS class namesClass: 'card mx-spacing-top-large'
StyleInline CSSStyle: 'padding: 16px;'
DynamicClassesRuntime-computed CSS classes (expression; stacks on Class)DynamicClasses: if $currentObject/IsActive then 'is-active' else ''
EditableEdit control: Always, Never, or a condition expression (bare or quoted)Editable: NEVER or Editable: $currentObject/Locked = false
VisibleVisibility expressionVisible: '$showField'
DesignPropertiesAtlas design propertiesDesignProperties: ('Spacing top': 'Large')

Attributes of a Page or Snippet Parameter

A bare attribute name binds to the enclosing data container’s object. To bind an attribute of a page or snippet parameter instead, whatever container the widget is in, write $Param.Attr. This works for a pluggable widget’s attribute property set by its own key too, such as a Custom chart’s eventDataAttribute: $Ctx.EventData. The qualified name of another entity’s attribute (Module.Other.Attr) is refused, because stored without the parameter it is mx check CE7247.

An enclosing data view is named the same way: inside a data view nested in dvOuter, $dvOuter.Attr reads the outer data view’s object, and $dvOuter/Assoc/Attr reads over an association from it. This works on input widgets, on a pluggable widget’s attribute property (markdown md (stringAttribute: $dvOuter.Output)) and in an attribute-based visibility condition (Visible: $dvOuter.HasError in (true)). It is the form Studio Pro stores for these bindings.

Parameters

module.Name
The qualified name of the page (Module.PageName). The module must already exist.
Params: ( ... )
Optional page parameters. Each parameter has a $-prefixed name and an entity type.
Title: 'title'
The display title of the page. Required.
Layout: Module.LayoutName
The page layout. Must reference an existing layout. Required.
FOLDER 'path'
Optional folder within the module, as a clause after the name. Nested folders use /. The Folder: 'path' property is its deprecated alias (MDL-DEPR105).
Variables: ( ... )
Optional page variables with type and default expression.

Examples

Edit page with a data view and form fields:

CREATE PAGE MyModule.Customer_Edit
(
    Params: ( $Customer: MyModule.Customer ),
    Title: 'Edit Customer',
    Layout: Atlas_Core.PopupLayout
)
{
    DATAVIEW dvCustomer (DataSource: $Customer) {
        TEXTBOX txtName (Label: 'Name', Attribute: Name)
        TEXTBOX txtEmail (Label: 'Email', Attribute: Email)
        COMBOBOX cbStatus (Label: 'Status', Attribute: Status)

        FOOTER {
            ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)
            ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
        }
    }
};

Overview page with a data grid:

CREATE PAGE Sales.Order_Overview FOLDER 'Orders'
(
    Title: 'Orders',
    Layout: Atlas_Core.Atlas_Default
)
{
    DATAGRID dgOrders (DataSource: DATABASE Sales.Order, PageSize: 20) {
        COLUMN (Attribute: OrderId, Caption: 'Order #')
        COLUMN (Attribute: OrderDate, Caption: 'Date')
        COLUMN (Attribute: Status, Caption: 'Status')
        COLUMN (Attribute: TotalAmount, Caption: 'Amount', Alignment: right)
        CONTROLBAR {
            ACTIONBUTTON btnNew (Caption: 'New Order', Action: SHOW PAGE Sales.Order_Edit, ButtonStyle: Primary)
        }
    }
};

Page with layout grid and containers:

CREATE PAGE MyModule.Dashboard
(
    Title: 'Dashboard',
    Layout: Atlas_Core.Atlas_Default
)
{
    LAYOUTGRID lgMain {
        ROW {
            COLUMN (Class: 'col-md-8') {
                CONTAINER cntRecent (Class: 'card') {
                    LISTVIEW lvRecent (DataSource: DATABASE MyModule.RecentActivity, PageSize: 5) {
                        DYNAMICTEXT txtDesc (Attribute: Description)
                    }
                }
            }
            COLUMN (Class: 'col-md-4') {
                CONTAINER cntStats (Class: 'card') {
                    DATAVIEW dvStats (DataSource: MICROFLOW MyModule.DS_DashboardStats) {
                        DYNAMICTEXT txtCount (Attribute: TotalCount)
                    }
                }
            }
        }
    }
};

Page with snippet call:

CREATE PAGE MyModule.Customer_Detail
(
    Params: ( $Customer: MyModule.Customer ),
    Title: 'Customer Detail',
    Layout: Atlas_Core.Atlas_Default
)
{
    DATAVIEW dvCustomer (DataSource: $Customer) {
        SNIPPETCALL snpHeader (Snippet: MyModule.CustomerHeader)
        TEXTBOX txtName (Label: 'Name', Attribute: Name)
        TEXTBOX txtEmail (Label: 'Email', Attribute: Email)
    }
};

Page with a gallery and selection-driven detail:

CREATE PAGE Sales.Product_Gallery
(
    Title: 'Products',
    Layout: Atlas_Core.Atlas_Default
)
{
    LAYOUTGRID lgMain {
        ROW {
            COLUMN (Class: 'col-md-6') {
                GALLERY galProducts (DataSource: DATABASE Sales.Product, PageSize: 12) {
                    DYNAMICTEXT txtName (Attribute: Name)
                    DYNAMICTEXT txtPrice (Attribute: Price)
                }
            }
            COLUMN (Class: 'col-md-6') {
                DATAVIEW dvDetail (DataSource: SELECTION galProducts) {
                    TEXTBOX txtName (Label: 'Product', Attribute: Name)
                    TEXTBOX txtDesc (Label: 'Description', Attribute: Description)
                }
            }
        }
    }
};

Page with page variables and conditional visibility:

CREATE PAGE MyModule.AdvancedForm
(
    Params: ( $Item: MyModule.Item ),
    Title: 'Advanced Form',
    Layout: Atlas_Core.Atlas_Default,
    Variables: ( $showAdvanced: Boolean = false )
)
{
    DATAVIEW dvItem (DataSource: $Item) {
        TEXTBOX txtName (Label: 'Name', Attribute: Name)
        ACTIONBUTTON btnToggle (Caption: 'Show Advanced', Action: CALL NANOFLOW MyModule.NAV_Toggle)
        CONTAINER cntAdvanced (Visible: '$showAdvanced') {
            TEXTAREA taNotes (Label: 'Notes', Attribute: Notes)
        }
        FOOTER {
            ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)
            ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
        }
    }
};

Page with microflow data source:

CREATE PAGE Reports.MonthlySummary
(
    Title: 'Monthly Summary',
    Layout: Atlas_Core.Atlas_Default
)
{
    DATAVIEW dvReport (DataSource: MICROFLOW Reports.DS_GetMonthlySummary) {
        DYNAMICTEXT txtPeriod (Attribute: Period)
        DYNAMICTEXT txtRevenue (Attribute: TotalRevenue)
        DYNAMICTEXT txtOrders (Attribute: OrderCount)
    }
};

See Also

CREATE SNIPPET, ALTER PAGE, DROP PAGE, DESCRIBE PAGE, GRANT VIEW ON PAGE

CREATE SNIPPET

Synopsis

CREATE [ OR REPLACE ] SNIPPET module.Name [ FOLDER 'path' ]
[(
    Params: ( $param : Module.Entity | Type [, ...] )
)]
{
    widget_tree
}

Description

Creates a reusable widget fragment (snippet) in the specified module. Snippets contain a widget tree that can be embedded in pages or other snippets using the SNIPPETCALL widget.

If OR REPLACE is specified and a snippet with the same qualified name already exists, it is replaced.

Snippets support the same widget types and syntax as pages. The key difference is that snippets do not have a Title or Layout – they are fragments meant to be included within a page’s layout.

Snippet Parameters

Snippets can optionally declare parameters. When a snippet has parameters, the SNIPPETCALL widget that embeds it must provide the corresponding values.

Folder Placement

The optional FOLDER 'path' clause, after the name, places the snippet in a subfolder within the module.

Parameters

module.Name

The qualified name of the snippet (Module.SnippetName). The module must already exist.

Params: ( ... )

Optional snippet parameters. Each parameter has a $-prefixed name and an entity type (Module.Entity).

A snippet parameter cannot be a primitive. Mendix rejects one with CE0046 “Invalid data type ‘String’.” — a page parameter may be a primitive, a snippet parameter may not — so mxcli check refuses it as MDL087 rather than letting it reach a build. To parameterise a snippet on a value, keep the primitive on the calling page’s parameters, or pass an object and read the member inside the snippet.

FOLDER 'path'

Optional folder path within the module, as a clause after the name. The Folder: 'path' property is its deprecated alias (MDL-DEPR105).

Examples

Simple snippet with a header:

CREATE SNIPPET MyModule.CustomerHeader
(
    Params: ( $Customer: MyModule.Customer )
)
{
    CONTAINER cntHeader (Class: 'card-header') {
        DATAVIEW dvCustomer (DataSource: $Customer) {
            DYNAMICTEXT txtName (Attribute: Name)
            DYNAMICTEXT txtEmail (Attribute: Email)
        }
    }
};

Snippet with form fields:

CREATE SNIPPET MyModule.AddressFields
(
    Params: ( $Address: MyModule.Address )
)
{
    DATAVIEW dvAddress (DataSource: $Address) {
        TEXTBOX txtStreet (Label: 'Street', Attribute: Street)
        TEXTBOX txtCity (Label: 'City', Attribute: City)
        TEXTBOX txtZip (Label: 'Zip Code', Attribute: ZipCode)
        TEXTBOX txtCountry (Label: 'Country', Attribute: Country)
    }
};

Snippet without parameters:

CREATE SNIPPET MyModule.AppFooter
{
    CONTAINER cntFooter (Class: 'app-footer') {
        DYNAMICTEXT txtVersion (Content: 'Version 1.0')
    }
};

Embedding a snippet in a page:

CREATE PAGE MyModule.Customer_Edit
(
    Params: ( $Customer: MyModule.Customer ),
    Title: 'Edit Customer',
    Layout: Atlas_Core.PopupLayout
)
{
    DATAVIEW dvCustomer (DataSource: $Customer) {
        SNIPPETCALL snpAddress (Snippet: MyModule.AddressFields)
        FOOTER {
            ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)
        }
    }
};

See Also

CREATE PAGE, ALTER PAGE, DROP PAGE

ALTER PAGE / ALTER SNIPPET

Synopsis

ALTER PAGE module.Name {
    operations
}

ALTER SNIPPET module.Name {
    operations
}

ALTER LAYOUT module.Name { … } takes the same operations. This is the generic ALTER — set / insert / replace / drop against a target — which every document type shares; a page’s targets are widget names, DataGrid 2 columns grid column(Attr) / grid column('Caption'), navigation list items nav item(widget) / nav item('Text') / nav item@n, a legacy table cell table1 cell(row, col), or a layout region container.top.

The older spellings still run and warn with a deprecation code:

Old spellingWrite insteadCode
SET Caption = 'x' ON w, SET (A = 1, B = 2) ON wSET (Caption: 'x') ON w, SET (A: 1, B: 2) ON wMDL-DEPR101
SET Caption: 'x' ON w (no parentheses)SET (Caption: 'x') ON wMDL-DEPR102
DROP WIDGET a, bDROP a, bMDL-DEPR103

Where each operation is one of:

-- Set a property on a widget
SET (property: value) ON widgetName;
SET ( property1: value1, property2: value2 ) ON widgetName;

-- Set a page-level property (no ON clause)
SET (Title: 'New Title');

-- Set a pluggable widget property (quoted name)
SET ('propertyName': value) ON widgetName;

-- Insert widgets before or after a target
INSERT BEFORE widgetName { widget_definitions };
INSERT AFTER widgetName { widget_definitions };

-- Insert widgets as the last children of a container
INSERT INTO containerName { widget_definitions };

-- Remove widgets
DROP widgetName1, widgetName2;

-- Replace a widget with new widgets
REPLACE widgetName WITH { widget_definitions };

-- Add a page variable
ADD Variables $name : type = <expression>;

-- Drop a page variable
DROP Variables $name;

-- Change page layout (auto-map placeholders by name)
SET Layout = module.LayoutName;

-- Change layout with explicit placeholder mapping
SET Layout = module.LayoutName MAP (OldPlaceholder AS NewPlaceholder);

Description

Modifies an existing page or snippet in-place without requiring a full CREATE OR REPLACE. This is useful when you need to make targeted changes to a page while preserving the rest of its widget tree, including any unsupported or third-party widget types that cannot be round-tripped through MDL.

ALTER PAGE works directly on the raw BSON widget tree, so it preserves widgets and properties that MDL does not explicitly support.

SET

Sets one or more properties on a named widget. Widget names are assigned during CREATE PAGE and can be discovered with DESCRIBE PAGE.

Supported standard properties: Caption, Label, ButtonStyle, Class, Style, Editable, Visible, Name.

For page-level properties (like Title), omit the ON clause.

For pluggable widget properties, use quoted property names (e.g., 'showLabel').

A pluggable widget’s attribute property is bound the way CREATE PAGE binds it. A bare name (set (eventDataAttribute: JsonData) on chart1) belongs to the entity the property binds to: the widget’s own datasource when the property is linked to one, otherwise the enclosing data container. Module.Entity.Attr must be an attribute of that entity or of one of its generalizations. $Param.Attr reads an attribute of a page or snippet parameter instead. A name the bound entity lacks (CE1613), and another entity’s qualified attribute (CE7247), are refused by check and exec alike.

INSERT BEFORE / INSERT AFTER

Inserts new widgets immediately before or after a named widget within its parent container. The new widgets use the same syntax as in CREATE PAGE.

INSERT INTO

Appends new widgets as the last children of a named container. This is the only way to fill an empty container, and the natural way to add a widget to a container or data view without needing a sibling to anchor to. Widgets inserted into a data view take that data view’s entity as their context.

Supported on simple containers (container/DivContainer, data view, group box, tab page, scroll-container region). A layout grid (rows/columns) has no single child list — insert relative to a widget inside the target column instead. A tab container’s children are tab pages; see below.

A pluggable widget’s child-widget slots have no name of their own, so they are addressed by the widget and the slot keyword describe prints: insert into dataGrid1.controlbar { actionbutton … } adds to a Data grid 2’s control bar (even after its last button was dropped), replace dataGrid1.controlbar with { … } swaps its content, and drop dataGrid1.controlbar empties it. The widget’s own property key (dataGrid1.filtersPlaceholder) works too. insert into dataGrid1 on the widget itself is refused with the slot addresses it has.

Adding Tab Pages

A tab container holds tab pages, and only tab pages. INSERT INTO the tab container appends one; INSERT BEFORE / INSERT AFTER a tab page places it next to that sibling. The container keeps its default tab.

mdl 1;
alter page ModT.TabsPage {
  insert after tpOne { tabpage tpTwo (Caption: 'Two') { dynamictext txtTwo (Content: 'two') } }
};
alter page ModT.TabsPage {
  insert into tabsMain { tabpage tpThree (Caption: 'Three') { dynamictext txtThree (Content: 'three') } }
};

A tab page cannot go next to an ordinary widget or inside another tab page, and one INSERT cannot mix tab pages with widgets. Dropping, replacing or reordering tab pages still needs CREATE OR MODIFY PAGE.

A navigation list holds items, and only items. INSERT INTO the list appends one and leaves the existing items as they are. The widgets inside an item are targets by their own names.

mdl 1;
alter snippet Shop.Menu {
  insert into navList1 { item (Action: show page Shop.Home) { dynamictext t2 (Content: 'Second') } }
};
alter snippet Shop.Menu {
  insert after t2 { dynamictext t3 (Content: 'Third') }
};

A navigation list item has no stored name either, so — like a DataGrid 2 column — it is addressed by what it holds or where it is:

AddressMeans
nav item(t1)the item holding the widget named t1
nav item('Home')the item showing the text Home (any language)
nav item@2the second item
nav item('Home')@2the second of several items showing Home

An address that matches several items is refused unless @n picks one, and a miss lists every item’s addresses. Prefer the content forms: a position moves when an item is inserted before it.

mdl 1;
alter snippet Shop.Menu {
  insert before navList1 item@1 { item (Action: show page Shop.Orders) { dynamictext t0 (Content: 'Orders') } }
  insert after navList1 item(t1) { item (Action: show page Shop.Home) { dynamictext t5 (Content: 'Home') } }
  set (Action: show page Shop.Orders) on navList1 item('Home')@2;
  insert into navList1 item('Orders') { dynamictext t0b (Content: 'All orders') }
  replace navList1 item(t2) with { item (Action: show page Shop.Home) { dynamictext t2 (Content: 'Second') } }
  drop navList1 item@3;
};

Only items go next to an item or in its place: INSERT BEFORE / AFTER an item and REPLACE of one take item blocks, and widgets go INTO an item.

Pluggable Widget Item Operations

A pluggable widget’s object-list item — an Accordion group, a Pop-up menu item — stores no name either (a name written on group g1 is not kept), and is addressed the same way, on the widget that owns the list:

AddressMeans
acc1 item@2the second group — INSERT INTO fills its body
acc1 item('One')the group whose text (headerText) is One
acc1 item(btnIn)the group holding the widget btnIn
acc1 item@1.headercontentanother slot of the group, by its keyword

The body is the slot describe prints as the item’s children (content, or the item’s first widget slot); any other slot is named with .slot, spelled as describe prints its block.

mdl 1;
alter page Shop.Faq {
  insert into acc1 item@2 { dynamictext txtAnswer (Content: 'Yes.') };
  set (headerRenderMode: 'custom') on acc1 item@1;
  insert into acc1 item@1.headercontent { dynamictext txtHead (Content: 'Shipping') };
  replace acc1 item('Returns').content with { dynamictext txtRet (Content: '30 days') };
  drop acc1 item@3;
};
  • A slot the widget’s configuration hides is refused, naming the property that shows it: an Accordion hides a group’s headercontent while headerRenderMode is 'text' (the default). Widgets written there are not rendered and fail the build with CE0463. Set the property first — in the same statement is fine, as above.
  • A SET that switches a group’s mode keeps the texts it hides or shows as CREATE stores them. Switching back to 'text' needs the text in the same statement — set (headerRenderMode: 'text', headerText: 'Shipping') on acc1 item@1 — since the stored schema has no default to fill in.
  • INSERT BEFORE / AFTER an item (adding a new group) and REPLACE of a whole item are not supported; use drop and create or modify page.

Legacy Table Cell Operations

A legacy (Studio Pro) table’s cell stores no name, only its position, so it is addressed as table1 cell(row, column), 1-based from the top-left. describe lists the cells under the table’s line, with what each holds:

-- Forms$Table (table1)  -- NOT re-executable: …
--   cell(1,1): txtName
--   cell(1,2): (empty)
mdl 1;
alter page Shop.Customer_Edit {
  insert into table1 cell(1,2) { textbox tbName (Attribute: Name) };
  replace table1 cell(1,1) with { dynamictext txtLabel (Content: 'Name') };
  drop table1 cell(2,1);
};
  • insert into appends to the cell; replace swaps its content; drop empties it — the cell itself stays, the table’s grid needs it.
  • Widgets in a cell bind in the data context around the table, as anywhere else.
  • A position covered by a spanning cell is refused, naming the cell to address.
  • INSERT BEFORE / AFTER a cell is refused: rows and columns cannot be added. A widget already in a cell is reachable by its own name.
  • MDL cannot create a table, so these statements work only on a page that already has one.

DROP

Removes one or more widgets by name. The widget and all its children are removed from the tree.

REPLACE

Replaces a widget (and its entire subtree) with one or more new widgets.

DataGrid Column Operations

A DataGrid 2 column has no stored name. It is addressed by the attribute or the caption DESCRIBE PAGE prints in it: grid column(Attr), grid column('Caption'). An address two columns share is refused unless @n picks one.

SET (Caption: 'Product SKU') ON dgProducts column(Code)
DROP dgProducts column('Old column')
INSERT AFTER dgProducts column(Price) { COLUMN (Attribute: Margin) }
REPLACE dgProducts column(Description) WITH { COLUMN (Attribute: Notes) }
DROP dgProducts column(Name)@2

The older dotted form gridName.columnName still works, matching a name mxcli derives from the attribute or caption.

SET Layout

Changes the page’s layout without rebuilding the widget tree. Placeholder names are auto-mapped by default. If the new layout has different placeholder names, use MAP to specify the mapping.

A repoint that would leave the page bound to a placeholder the target layout does not declare is refused, and the error names MAP as the remedy. The check runs after MAP is applied, so mapping onto an existing placeholder is always accepted. To move many pages at once, see ALTER LAYOUT, which documents the bulk ALTER PAGES … SET LAYOUT form.

Not supported for snippets (snippets don’t have layouts).

ADD Variables / DROP Variables

Adds or removes page-level variables. Page variables are typed values with default expressions, available for conditional visibility and dynamic behavior.

Parameters

module.Name
The qualified name of the page or snippet to modify. Must already exist.
widgetName
The name of a widget within the page. Use DESCRIBE PAGE to list widget names.
property
A widget property name. Standard properties are unquoted. Pluggable widget properties use single quotes.
value
The new property value. Strings are quoted. Booleans and enumerations are unquoted.

Examples

Change button caption and style:

ALTER PAGE Sales.Order_Edit {
    SET (Caption: 'Save & Close', ButtonStyle: Success) ON btnSave;
};

Remove an unused widget and add a new field:

ALTER PAGE Sales.Order_Edit {
    DROP txtUnused;
    INSERT AFTER txtEmail {
        TEXTBOX txtPhone (Label: 'Phone', Attribute: Phone)
    }
};

Replace a widget with multiple new widgets:

ALTER PAGE Sales.Order_Edit {
    REPLACE txtOldField WITH {
        TEXTBOX txtFirstName (Label: 'First Name', Attribute: FirstName)
        TEXTBOX txtLastName (Label: 'Last Name', Attribute: LastName)
    }
};

Set a page-level property:

ALTER PAGE Sales.Order_Edit {
    SET (Title: 'Edit Order Details');
};

Set a pluggable widget property:

ALTER PAGE Sales.Order_Edit {
    SET ('showLabel': false) ON cbStatus;
};

Add and drop page variables:

mdl 1;
ALTER PAGE Sales.Order_Edit {
    ADD Variables $showAdvanced : Boolean = false;
};

ALTER PAGE Sales.Order_Edit {
    DROP Variables $showAdvanced;
};

Change page layout (preserves all widgets):

ALTER PAGE Sales.Order_Edit {
    SET Layout = Atlas_Core.Atlas_Default;
};

Change layout with explicit placeholder mapping:

ALTER PAGE Sales.Order_Edit {
    SET Layout = Atlas_Core.Atlas_SideBar MAP (Main AS Content, Extra AS Sidebar);
};

Modify a snippet:

ALTER SNIPPET MyModule.NavMenu {
    SET (Caption: 'Dashboard') ON btnHome;
    INSERT AFTER btnHome {
        ACTIONBUTTON btnReports (Caption: 'Reports', Action: PAGE MyModule.Reports)
    }
};

Combined operations in a single ALTER:

ALTER PAGE MyModule.Customer_Edit {
    SET (Title: 'Customer Details');
    SET (Caption: 'Update', ButtonStyle: Primary) ON btnSave;
    DROP txtObsolete;
    INSERT BEFORE txtEmail {
        TEXTBOX txtPhone (Label: 'Phone', Attribute: Phone)
    }
    INSERT AFTER txtEmail {
        COMBOBOX cbCategory (Label: 'Category', Attribute: Category)
    }
};

See Also

CREATE PAGE, CREATE SNIPPET, DESCRIBE PAGE, DROP PAGE

DROP PAGE / SNIPPET

Synopsis

mdl 1;
DROP PAGE module.Name;

DROP SNIPPET module.Name;

Description

Removes a page or snippet from the project. The page or snippet must exist; otherwise an error is raised.

Dropping a page or snippet does not automatically update references to it from microflows (e.g., SHOW PAGE), navigation menus, other pages (SNIPPETCALL), or security rules. Use LIST IMPACT OF module.Name before dropping to identify all references that will break.

Parameters

module.Name
The qualified name of the page or snippet to drop (Module.PageName or Module.SnippetName).

Examples

Drop a page:

DROP PAGE Sales.Order_Edit;

Drop a snippet:

DROP SNIPPET MyModule.CustomerHeader;

Check impact before dropping:

mdl 1;
-- See what references this page
LIST IMPACT OF Sales.Order_Edit;

-- Then drop if safe
DROP PAGE Sales.Order_Edit;

See Also

CREATE PAGE, CREATE SNIPPET, ALTER PAGE, LIST IMPACT

CREATE LAYOUT

Synopsis

CREATE [OR REPLACE] LAYOUT module.Name
(
    layouttype: 'Responsive',
    class: 'layout-atlas layout-atlas-responsive-topbar'
)
{
    SCROLLCONTAINER name [( layoutmode: 'Headline' | 'Sidebar' )] {
        REGION top | right | bottom | left | center ( region_properties ) {
            widgets
        }
    }
}

Description

Creates a layout — the frame every page renders inside: the topbar, the navigation sidebar, and the hole a page’s own content drops into. A page names one with its Layout property, and its widgets land in the layout’s Main placeholder.

A layout’s body uses the whole page widget vocabulary plus four element types that only a layout has: SCROLLCONTAINER, REGION, PLACEHOLDER and NAVIGATIONTREE (with MENUBAR for the topbar’s horizontal menu).

OR REPLACE rewrites an existing layout. To change one without rebuilding it, use ALTER LAYOUT instead — it edits the stored document and leaves alone anything it was not asked about, including widgets MDL cannot express.

Do not write into a Marketplace module

Mendix’s own guidance is “Do not change the supplied layouts. Either create a separate module with the custom layouts … or create your own.” A Marketplace update replaces the module wholesale and every local edit is gone, silently.

CREATE LAYOUT refuses a Marketplace target for that reason:

Error: layout Atlas_Core.T2: Atlas_Core is a marketplace module — a layout
written there is overwritten by the next module update. Create the layout in a
module of your own (Mendix's own guidance) and point pages at it with
ALTER PAGE … SET LAYOUT.

The usual starting point is a copy of an Atlas layout. DESCRIBE LAYOUT emits re-executable MDL, so describe → rename → run is the copy operation:

mxcli -p app.mpr -c "describe layout Atlas_Core.Atlas_Default" > mine.mdl
# change the qualified name to your own module, then:
mxcli exec mine.mdl -p app.mpr

Read the describe output before running it. A widget MDL cannot express is emitted as a comment ending -- NOT re-executable, naming exactly what a re-run would drop.

Atlas’s responsive sidebar toggle is the sidebartoggle widget, so a copied layout keeps its slide-in navigation on phones and tablets:

sidebartoggle sidebarToggle3 (
  Tooltip: 'Toggle Menu', ButtonStyle: Primary,
  Icon: 'Atlas_Core.Atlas_Filled.navigation-menu', Class: 'toggle-btn'
)

It takes an action button’s Caption, Tooltip, ButtonStyle and Icon, and RenderMode: Link to render as a link; it has no Action.

The phone and tablet layouts keep their header bar in a layoutheader, whose two sides are spelled like a scroll container’s regions:

layoutheader header1 {
  region left { sidebartoggle sidebarToggle2 (RenderMode: Link, Icon: 'Atlas_Core.Atlas_Filled.navigation-menu') }
  region right { placeholder HeaderRight }
}

A native layout keeps three things beside its content tree: the placeholder on the header’s right, whether the bottom bar shows, and the sidebar.

create layout MyModule.Native_SideMenu (layouttype: 'Default', showbottombar: true) {
  placeholder Main
  region header { placeholder Header }
  region sidebar { placeholder Placeholder1 }
};

Parameters

module.Name

The qualified name of the layout (Module.LayoutName). Must be a module you own.

layouttype

Required. Omitting it is an error, not a default: Error: layout needs a layouttype.

PlatformValues
WebResponsive, Phone, Tablet, ModalPopup
NativeDefault, Popup

The two sets are disjoint, so the platform is inferred from the type — there is no separate native: flag to contradict it.

class

The layout’s own CSS class. Not decoration — Atlas scopes around two dozen of its layout rules to .layout-atlas and its variants, and every Atlas layout with chrome carries one. A layout written without it builds cleanly, passes mx check, and renders with no topbar bar and no sidebar rail.

ShapeClass
Topbar navigationlayout-atlas layout-atlas-responsive-topbar
Sidebar navigationlayout-atlas layout-atlas-responsive-default
Popup(none — PopupLayout is bare)
style

Inline CSS on the layout element.

Layout

The layout this one is built on — see A layout built on another layout.

layouttype, class, style and Layout are the only header properties. Anything else is an error rather than an ignored key:

Error: layout MyModule.T1: unknown property "bogus" (a layout header takes
layouttype, class, style and layout; which placeholder is "main" is set by
naming one Main, not by a property)

A layout built on another layout

Studio Pro lets a layout wrap another one rather than have a tree of its own (ConversationalUI’s Layout_MasterBase wraps Atlas_Core.Atlas_Default). Name the outer layout with Layout: and write the body exactly as a page body: PLACEHOLDER X { … } fills the outer layout’s placeholder X, and bare widgets fill its Main. The placeholders the new layout declares for its own pages go inside those blocks, bodiless as anywhere else in a layout:

CREATE OR MODIFY LAYOUT MyModule.Layout_Framed (
  layouttype: 'Responsive',
  Layout: Atlas_Core.Atlas_Default
) {
  PLACEHOLDER Main {
    CONTAINER banner (Class: 'card') {
      DYNAMICTEXT heading (Content: 'Framed area', RenderMode: H3)
    }
    PLACEHOLDER Main
  }
};
  • The layout still needs exactly one declared Main (CE0848), counted inside the blocks.
  • A block naming a placeholder the outer layout does not declare is refused; mxbuild would report it as CE1613.
  • Only web layouts can be built on another layout.
  • DESCRIBE LAYOUT prints this form, with one block per stored argument.

Scroll container properties

PropertyValuesNotes
layoutmodeHeadline, SidebarThe frame. Headline runs the top region across the full width and starts the left one under it; Sidebar runs the left region the full height and puts the top bar beside it (Atlas’s Atlas_SideBar and Atlas_TopBar). Unset is Headline, and DESCRIBE LAYOUT prints it only when it is Sidebar. Change it on a stored layout with ALTER LAYOUT … { SET (layoutmode: 'Sidebar') ON layoutContainer; }

Region properties

PropertyValuesNotes
sizeintegerUnset is Studio Pro’s 200
sizemodeFixed, Pixels, AutoUnset is Auto
togglemodeNone, PushContentAside, SlideOverContent, ShrinkContentInitiallyOpen, ShrinkContentInitiallyClosedHow a SIDEBARTOGGLE opens and closes the region. Unset is None
classCSS classe.g. region-topbar, region-content. The only appearance property a region stores: style on one is refused (MDL-WIDGET52)

The four elements only a layout has

ElementMDLNotes
Scroll containerSCROLLCONTAINER name { … }The layout’s root. Its children are regions, never widgets
RegionREGION top | right | bottom | left | centerFive named slots, not a list. One region per slot
PlaceholderPLACEHOLDER MainThe hole a page’s content goes into. No properties, no body
Navigation treeNAVIGATIONTREE name (profile: 'Responsive')The sidebar menu — vertical
Menu barMENUBAR name (profile: 'Responsive')The topbar menu — horizontal
Simple menu barSIMPLEMENUBAR name (menu: Module.Menu [, orientation: Vertical])A phone layout’s bottom bar, as Atlas’s Phone_BottomBar has it. orientation defaults to Horizontal

Every menu widget renders either a navigation profile (profile: 'Responsive') or a menu document (menu: Module.Menu, created with CREATE MENU) — the two are alternatives for the same slot, and naming both is refused. With neither, the Responsive profile is used.

Examples

A topbar layout, which is the shape mxcli new scaffolds:

CREATE OR MODIFY LAYOUT MyModule.App_Default
(
    layouttype: 'Responsive',
    class: 'layout-atlas layout-atlas-responsive-topbar'
)
{
    SCROLLCONTAINER layoutContainer {
        REGION top (size: 60, sizemode: 'Fixed', class: 'region-topbar') {
            MENUBAR mainMenu (profile: 'Responsive')
        }
        REGION center (class: 'region-content') {
            PLACEHOLDER Main
        }
    }
};

A sidebar layout, with a navigation tree in the left region running the full height (layoutmode: 'Sidebar'; leave it out and the top region spans the page):

CREATE LAYOUT MyModule.App_Sidebar
(
    layouttype: 'Responsive',
    class: 'layout-atlas layout-atlas-responsive-sidebar'
)
{
    SCROLLCONTAINER layoutContainer (layoutmode: 'Sidebar') {
        REGION left (size: 232, sizemode: 'Pixels', class: 'region-sidebar') {
            NAVIGATIONTREE navMenu (profile: 'Responsive')
        }
        REGION center (class: 'region-content') {
            PLACEHOLDER Main
        }
    }
};

A page then names the layout, and its widgets land in Main:

CREATE PAGE MyModule.Dashboard
(
    Title: 'Dashboard',
    Layout: MyModule.App_Default
)
{
    CONTAINER cntMain {
        DYNAMICTEXT txtWelcome (Content: 'Welcome')
    }
};

Notes

  • A layout must declare at least one placeholder. Without one a page’s content has nowhere to go, and the statement is refused: layout "X" declares no placeholder.
  • Name one placeholder Main. Forms$Layout has no property recording which placeholder is the main one — the convention is the mechanism, and all 22 layouts Atlas ships follow it. There is deliberately no mainplaceholder: property: writing the underlying key produces a layout that builds cleanly and that Studio Pro cannot open.
  • A placeholder’s name is API. Pages bind to it as Module.Layout.Name. Renaming one unbinds every page that used it — those pages still build, and their content vanishes.

See Also

ALTER LAYOUT, CREATE PAGE, ALTER PAGE / ALTER SNIPPET

ALTER LAYOUT

Synopsis

ALTER LAYOUT module.Name {
    operations
}

Where each operation is one of:

-- Insert widgets as the last children of a container
INSERT INTO containerName { widget_definitions };

-- Insert widgets into a scroll-container region (addressed by slot)
INSERT INTO scrollContainerName.top { widget_definitions };
INSERT INTO scrollContainerName.left { widget_definitions };

-- Insert widgets before or after a target
INSERT BEFORE widgetName { widget_definitions };
INSERT AFTER widgetName { widget_definitions };

-- Set a property on a widget
SET (property: value) ON widgetName;

-- Remove widgets
DROP widgetName1, widgetName2;

-- Replace a widget with new widgets
REPLACE widgetName WITH { widget_definitions };

Repointing pages onto a different layout:

-- One page
ALTER PAGE module.Page { SET Layout = module.Layout [MAP (Old AS New, …)]; };

-- Many at once — the migration form
ALTER PAGES [IN module] SET LAYOUT = module.Layout
    [MAP (Old AS New, …)] [WHERE LAYOUT = module.OldLayout];

Description

Modifies an existing layout in place. ALTER LAYOUT takes the whole ALTER PAGE operation vocabulary, because a layout’s widget tree is a page’s plus four element types.

This is the tool for changing a layout you did not write. CREATE OR REPLACE LAYOUT rebuilds the document from your MDL, which means it can only reproduce what MDL can express; ALTER LAYOUT edits what is stored and leaves everything it was not asked about untouched — including widgets MDL has no syntax for, which a describe → rename → run copy drops (describe names each one in a -- NOT re-executable comment). An ALTER LAYOUT against it does not, and a CREATE OR REPLACE LAYOUT of the stored layout itself is refused while it holds one.

Like CREATE LAYOUT, it refuses a Marketplace target and names the copy-then-repoint route in the error.

Addressing a region

A scroll-container region has no name of its own — its slot is its identity — so it is addressed as <scrollContainerName>.<slot>, reusing the dotted widget reference that also serves DataGrid 2 columns. Which one is meant is decided by the named widget’s type.

Only INSERT INTO accepts a region. BEFORE and AFTER position a widget among its siblings, so name a widget for those.

An empty slot has no stored region to insert into. Add the region with CREATE OR REPLACE LAYOUT.

SET LAYOUT and the placeholder guard

A page binds to its layout’s placeholders by qualified name, so repointing rewrites both the layout reference and every placeholder binding.

Both forms refuse a repoint that would leave a page bound to a placeholder the target layout does not declare:

Error: layout MyModule.Sidebarish does not declare the placeholder Main that
this page binds to; it has Content. Add the placeholder to the layout, or remap
the binding: `set Layout = MyModule.Sidebarish map (Main as Content)`

Without the check the rewrite produces a dangling binding that mxbuild reports much later as CE1613, naming the page rather than the statement. The check runs after MAP is applied, since MAP is the remedy.

In the bulk form, pages in Marketplace modules are skipped and named rather than refused — a project-wide repoint that stopped dead on Administration’s pages would be unusable. A WHERE LAYOUT that names a layout which does not exist is an error, not a “0 pages” success for a typo:

Error: layout not found: MyModule.NoSuchLayout

Examples

Put a brand caption in the topbar of a layout you own:

ALTER LAYOUT MyModule.App_Default {
    INSERT INTO layoutContainer.top { DYNAMICTEXT brand (Content: 'My App') };
};

Change it, then remove it:

mdl 1;
ALTER LAYOUT MyModule.App_Default {
    SET (Content: 'Renamed') ON brand;
};

ALTER LAYOUT MyModule.App_Default {
    DROP brand;
};

Add a theme switcher snippet to the topbar:

ALTER LAYOUT MyModule.App_Default {
    INSERT INTO layoutContainer.top {
        SNIPPETCALL themeBar (Snippet: MyModule.SNIPPET_ThemeBar)
    };
};

Move one page onto a new layout:

ALTER PAGE MyModule.Home {
    SET Layout = MyModule.App_Default;
};

Move every page that is still on the Atlas default — the migration:

ALTER PAGES SET LAYOUT = MyModule.App_Default
    WHERE LAYOUT = Atlas_Core.Atlas_Default;

Scoped to one module, whatever each page is on now:

ALTER PAGES IN MyModule SET LAYOUT = MyModule.App_Default;

Repoint onto a layout whose placeholder has a different name:

ALTER PAGE MyModule.Split {
    SET Layout = MyModule.Minimal MAP (HeaderLeft AS Main);
};

Notes

  • DESCRIBE LAYOUT emits re-executable MDL, so it is the way to see what a layout currently contains before altering it.

See Also

CREATE LAYOUT, ALTER PAGE / ALTER SNIPPET, CREATE PAGE

Security Statements

Statements for managing project security: module roles, user roles, entity access rules, microflow and page access, demo users, and project-level security settings.

Mendix security operates at two levels. Module roles define permissions within a single module (entity access, microflow execution, page visibility). User roles aggregate module roles into project-wide identities assigned to end users.

Statements

StatementDescription
CREATE MODULE ROLECreate a role within a module
CREATE USER ROLECreate a project-level user role aggregating module roles
GRANTGrant entity, microflow, page, or nanoflow access to roles
REVOKERemove previously granted access
CREATE DEMO USERCreate a demo user for development and testing
UPDATE SECURITYRe-sync entity access rules with the domain model (CE0066)
StatementSyntax
Describe app securityDESCRIBE APP SECURITY
Show module rolesLIST MODULE ROLES [IN module]
Show user rolesLIST USER ROLES
Show demo usersLIST DEMO USERS
Show access on elementLIST ACCESS ON [ENTITY|MICROFLOW|PAGE|NANOFLOW] module.Name
Describe security matrixDESCRIBE SECURITY MATRIX [IN module]
Alter app security levelALTER APP SECURITY ( SecurityLevel: OFF|PROTOTYPE|PRODUCTION )
Toggle demo usersALTER APP SECURITY ( EnableDemoUsers: TRUE|FALSE )
Toggle strict modeALTER APP SECURITY ( StrictMode: TRUE|FALSE )
Toggle guest accessALTER APP SECURITY ( EnableGuestAccess: TRUE|FALSE [, GuestUserRole: UserRole] )
Drop module roleDROP MODULE ROLE module.Role
Drop user roleDROP USER ROLE [IF EXISTS] Name
Drop demo userDROP DEMO USER [IF EXISTS] 'username'
Alter user roleALTER USER ROLE Name ADD|DROP MODULE ROLES (module.Role, ...)

CREATE MODULE ROLE

Synopsis

CREATE [ OR MODIFY ] MODULE ROLE module.RoleName [ DESCRIPTION 'text' ]

Description

Creates a new module role within a module. Module roles are the foundation of Mendix security – they are referenced by entity access rules, microflow/page access grants, and aggregated into project-level user roles.

Each module role belongs to exactly one module. The role name must be unique within the module. By convention, common role names include User, Admin, Viewer, and Manager.

If the module does not exist, the statement returns an error. If the role exists, CREATE MODULE ROLE returns an error; CREATE OR MODIFY updates it instead, so a security script can be re-run. Without DESCRIPTION, CREATE OR MODIFY keeps the role’s stored description; DESCRIPTION '' clears it.

Parameters

module.RoleName
Qualified name consisting of the module name and the new role name, separated by a dot. The module must already exist.
DESCRIPTION 'text'
Optional human-readable description of the role’s purpose. Left out, a new role has none and an existing role keeps its own.

Examples

Create a basic module role:

CREATE MODULE ROLE Shop.Admin;

Create a role with a description:

CREATE MODULE ROLE Shop.Admin DESCRIPTION 'Full administrative access to shop module';

Create multiple roles for a module:

mdl 1;
CREATE MODULE ROLE HR.Manager DESCRIPTION 'Can manage employees';
CREATE MODULE ROLE HR.Employee DESCRIPTION 'Self-service access';
CREATE MODULE ROLE HR.Viewer DESCRIPTION 'Read-only access';

Make sure a role exists, keeping whatever description it has:

CREATE OR MODIFY MODULE ROLE HR.Manager;

See Also

CREATE USER ROLE, GRANT, REVOKE

CREATE USER ROLE

Synopsis

CREATE [ OR MODIFY ] USER ROLE Name [ (
    ModuleRoles: ( module.Role [, ...] ),
    Description: 'text',
    ManageAllRoles: true | false,
    ManageableRoles: ( UserRole [, ...] ),
    ManageUsersWithoutRoles: true | false,
    CheckSecurity: true | false
) ]

Description

Creates a project-level user role that aggregates module roles. User roles are assigned to end users (either directly or via demo users) and determine the combined set of permissions across all modules.

A user role name must be unique at the project level. It is not qualified with a module name because user roles span multiple modules.

The properties are a ( Key: value ) list. Every property is optional, and so is the list: CREATE USER ROLE Guest; creates a role with no module roles. A property left out gets Mendix’s default on a new role, and keeps its stored value under CREATE OR MODIFY. CREATE OR MODIFY adds the listed module roles to the ones the role already has; use ALTER USER ROLE … DROP MODULE ROLES to take one away.

The positional form CREATE USER ROLE Name (module.Role, ...) [MANAGE ALL ROLES] is deprecated (MDL-DEPR710). It still parses, and mxcli fmt --upgrade rewrites it.

Parameters

Name
The name of the user role. Not module-qualified. Must be unique among all user roles in the project.
ModuleRoles
The module roles the user role includes, each a qualified name (Module.RoleName). The module roles must already exist.
Description
The role’s description, as Studio Pro shows it.
ManageAllRoles
When true, users with this role can manage (assign/unassign) all user roles in the application. Typically reserved for administrator roles.
ManageableRoles
The user roles users with this role can manage, when ManageAllRoles is not true.
ManageUsersWithoutRoles
When true, users with this role can also manage users that have no role.
CheckSecurity
Studio Pro’s “Check security” setting for the role.

Examples

Create an administrator role with management privileges:

CREATE USER ROLE AppAdmin ( ModuleRoles: (Shop.Admin, System.Administrator), ManageAllRoles: true );

Create a regular user role:

CREATE USER ROLE AppUser ( ModuleRoles: (Shop.User, Notifications.Viewer) );

Create a role that spans multiple modules, with a description:

CREATE USER ROLE SalesManager (
    ModuleRoles: (Sales.Manager, Inventory.Viewer, Reports.User, Administration.User),
    Description: 'Runs the sales team',
    ManageableRoles: (SalesRep),
    CheckSecurity: true
);

See Also

CREATE MODULE ROLE, GRANT, CREATE DEMO USER

GRANT

Synopsis

-- Entity access
GRANT  rights  ON ENTITY module.Entity TO module.Role [, ...] [ WHERE [xpath] ]

-- Microflow access
GRANT EXECUTE ON MICROFLOW module.Name TO module.Role [, ...]

-- Page access
GRANT VIEW ON PAGE module.Name TO module.Role [, ...]

-- Nanoflow access
GRANT EXECUTE ON NANOFLOW module.Name TO module.Role [, ...]

Description

Grants access rights to module roles. There are four forms of the GRANT statement, each controlling a different kind of access.

Entity Access

The entity access form creates or updates an access rule on an entity for a given module role. GRANT is additive: if the role already has an access rule on the entity, the new rights are merged with existing ones. Existing permissions are never downgraded by a GRANT — use REVOKE to take access away.

A rule is identified by its role set and its WHERE constraint, so two GRANTs with different constraints produce two rules rather than overwriting one another. Mendix combines them: a role’s effective access is the union of every rule naming it.

Entity access rules control:

  • CREATE – whether the role can create new instances
  • DELETE – whether the role can delete instances
  • READ – which attributes the role can read
  • WRITE – which attributes the role can modify

For READ and WRITE, use * to include all members (attributes and associations), or specify a parenthesized list of specific attribute names.

The optional WHERE clause accepts an XPath constraint that restricts which objects the role can access. It is written in brackets, as everywhere else in MDL, so quotes inside it are not doubled: WHERE [Status = 'Open']. Sibling groups (WHERE [a][b]) are one constraint.

The older form GRANT module.Role ON module.Entity (rights) WHERE '[xpath]' — role first, XPath in a string — still parses, warns MDL-DEPR030, and mxcli fmt --upgrade rewrites it.

Microflow Access

The microflow access form grants execute permission on a microflow to one or more module roles. This controls whether the role can trigger the microflow (from pages, other microflows, or REST/web service calls).

Page Access

The page access form grants view permission on a page to one or more module roles. This controls whether the role can open the page.

Nanoflow Access

The nanoflow access form grants execute permission on a nanoflow to one or more module roles.

Default access of a new page, microflow or nanoflow

Document grants are additive too: a GRANT adds roles to the document’s allowed roles and never removes one.

A newly created page, microflow or nanoflow starts with no allowed roles, whatever its module holds. mxcli never creates a module role on its own: which roles exist, and which documents they open, is the author’s decision. Create the module role, map it into a user role, and grant the document in the same script:

mdl 1;
create module role Shop.User;
create or modify user role Customer ( ModuleRoles: (System.User, Shop.User) );
grant view on page Shop.Overview to Shop.User;

Earlier versions of mxcli created a <Module>.User role (description “Auto-created default role for mxcli document access”) in a module with no module roles and granted every new document to it. A project written by one of those versions still holds that role and those grants; mxcli leaves them as they are. Revoke them (revoke view on page Shop.Stub from Shop.User;) or drop the role if you did not intend them.

A rewrite keeps the stored roles: create or modify, and drop followed by create of the same name in one run. A create in a later run than the drop is a new document and starts again with no access, which MxBuild reports as CE0106 when a page, nanoflow or navigation uses the flow. mxcli check -p reports that case as MDL-SEC21.

Parameters

module.Role
The module role receiving the access grant. Must be a qualified name (Module.RoleName).
module.Entity
The target entity for entity access rules.
rights
A comma-separated list of access rights for entity access. Valid values:
  • CREATE – allow creating instances
  • DELETE – allow deleting instances
  • READ * – read all members
  • READ (Attr1, Attr2, ...) – read specific attributes
  • WRITE * – write all members
  • WRITE (Attr1, Attr2, ...) – write specific attributes
WHERE [xpath]
Optional XPath constraint for entity access, in brackets. Restricts which objects the rule applies to.
module.Name
The target microflow, nanoflow, or page.
TO module.Role [, ...]
One or more module roles receiving the access (for microflow, page, and nanoflow forms).

Examples

Grant full entity access:

GRANT CREATE, DELETE, READ *, WRITE * ON ENTITY Shop.Customer TO Shop.Admin;

Grant read-only access:

GRANT READ * ON ENTITY Shop.Customer TO Shop.Viewer;

Grant selective attribute access:

GRANT READ (Name, Email), WRITE (Email) ON ENTITY Shop.Customer TO Shop.User;

Grant entity access with an XPath constraint:

GRANT READ *, WRITE * ON ENTITY Shop.Order TO Shop.User
    WHERE [Status = 'Open'];

Grant microflow execution to multiple roles:

GRANT EXECUTE ON MICROFLOW Shop.ACT_Order_Process TO Shop.User, Shop.Admin;

Grant page visibility:

GRANT VIEW ON PAGE Shop.Order_Overview TO Shop.User, Shop.Admin;

Grant nanoflow execution:

GRANT EXECUTE ON NANOFLOW Shop.NAV_ValidateInput TO Shop.User;

Additive grant – add new attribute access without removing existing:

-- Viewer already has READ (Name, Email)
GRANT READ (Phone) ON ENTITY Shop.Customer TO Shop.Viewer;
-- Result: READ (Name, Email, Phone)

Inherited members

Mendix inheritance is multi-table: a child adds attributes to its parent’s, and all the parent’s members belong to the child. Name an inherited member in a GRANT exactly like one of the entity’s own; READ * and WRITE * cover them too.

mdl 1;
CREATE PERSISTENT ENTITY Docs.DocumentBase (DocName: String(200));
CREATE PERSISTENT ENTITY Docs.Contract EXTENDS Docs.DocumentBase (ContractNumber: String(50));

-- DocName inherited, ContractNumber own — no distinction at the call site
GRANT READ (DocName, ContractNumber) ON ENTITY Docs.Contract TO Docs.Viewer;

An access rule must carry an entry for every member, own and inherited. mxcli writes the members you did not grant with rights None; omitting them entirely is Mendix CE0066 “Entity access is out of date”, which masks the CE2729 “No read access to attribute” errors beneath it until Studio Pro’s Update security is clicked.

A member name that matches nothing is rejected rather than skipped:

Error: entity Docs.Contract has no member(s) DocNam; grant only names members
of the entity or of an entity it inherits from

User entities are the exception

An entity extending System.User is a user entity, and Mendix manages its inherited platform members (Name, Password, Blocked, …). Those must not appear in the access rule — listing them is CE0066. Grant only the entity’s own members; mxcli excludes the platform ones automatically.

mdl 1;
CREATE PERSISTENT ENTITY Docs.Employee EXTENDS System.User (EmployeeNo: String(20));
GRANT READ (EmployeeNo) ON ENTITY Docs.Employee TO Docs.Viewer;

See Also

REVOKE, CREATE MODULE ROLE, CREATE USER ROLE

REVOKE

Synopsis

-- Entity access (full -- removes entire rule)
REVOKE ALL ON ENTITY module.Entity FROM module.Role [, ...]

-- Entity access (partial -- downgrades specific rights)
REVOKE rights ON ENTITY module.Entity FROM module.Role [, ...]

-- Microflow access
REVOKE EXECUTE ON MICROFLOW module.Name FROM module.Role [, ...]

-- Page access
REVOKE VIEW ON PAGE module.Name FROM module.Role [, ...]

-- Nanoflow access
REVOKE EXECUTE ON NANOFLOW module.Name FROM module.Role [, ...]

Description

Removes previously granted access rights from module roles. Each form is the counterpart to the corresponding GRANT statement.

Entity Access

The entity revoke mirrors the entity grant: rights first, then ON ENTITY, then the roles after FROM. REVOKE ALL removes the entire entity access rule for the specified module roles on the entity. The older form with the role first, REVOKE module.Role ON module.Entity [( rights )], still parses and warns MDL-DEPR082; mxcli fmt --upgrade rewrites it.

With a rights list, performs a partial revoke: REVOKE READ (x) sets member x to no access. REVOKE WRITE (x) downgrades member x from ReadWrite to ReadOnly. REVOKE CREATE and REVOKE DELETE remove the structural permission. The access rule itself is preserved.

Microflow Access

Removes execute permission on a microflow from one or more module roles.

Page Access

Removes view permission on a page from one or more module roles.

Nanoflow Access

Removes execute permission on a nanoflow from one or more module roles.

Parameters

module.Role
The module role losing access. Must be a qualified name (Module.RoleName).
module.Entity
The entity whose access rule is removed or modified.
rights
Optional. A comma-separated list of rights to revoke (partial revoke). Same syntax as GRANT rights:
  • CREATE – revoke create permission
  • DELETE – revoke delete permission
  • READ * – revoke all read access
  • READ (Attr1, ...) – revoke read on specific attributes
  • WRITE * – downgrade all members from ReadWrite to ReadOnly
  • WRITE (Attr1, ...) – downgrade specific attributes from ReadWrite to ReadOnly
module.Name
The target microflow, nanoflow, or page.
FROM module.Role [, ...]
One or more module roles losing access (for microflow, page, and nanoflow forms).

Examples

Remove all entity access for a role:

REVOKE ALL ON ENTITY Shop.Customer FROM Shop.Viewer;

Partial revoke – remove read access on a specific attribute:

REVOKE READ (Notes) ON ENTITY Shop.Customer FROM Shop.User;

Partial revoke – downgrade write to read-only:

REVOKE WRITE (Email) ON ENTITY Shop.Customer FROM Shop.User;

Partial revoke – remove structural permission:

REVOKE DELETE ON ENTITY Shop.Customer FROM Shop.User;

Remove microflow execution from multiple roles:

REVOKE EXECUTE ON MICROFLOW Shop.ACT_Order_Process FROM Shop.Viewer;

Remove page visibility:

REVOKE VIEW ON PAGE Shop.Admin_Dashboard FROM Shop.User;

Remove nanoflow execution:

REVOKE EXECUTE ON NANOFLOW Shop.NAV_ValidateInput FROM Shop.Viewer;

See Also

GRANT, CREATE MODULE ROLE

CREATE DEMO USER

Synopsis

CREATE [ OR MODIFY ] DEMO USER 'username' (
    Password: 'password',
    [ Entity: module.Entity, ]
    UserRoles: ( UserRole [, ...] )
)

Description

Creates a demo user for development and testing. Demo users appear on the login screen when running the application locally, allowing quick login without manual credential entry.

Demo users require that project security has demo users enabled (ALTER APP SECURITY ( EnableDemoUsers: TRUE )).

The properties are a ( Key: value ) list, with the names Studio Pro gives them. The clause form PASSWORD 'password' [ ENTITY module.Entity ] ( UserRole, … ) is its deprecated alias (MDL-DEPR137); mxcli fmt --upgrade rewrites it.

The optional Entity property specifies which entity (a specialization of System.User) stores the demo user. If omitted, the system auto-detects the unique System.User subtype in the project (typically Administration.Account).

Parameters

'username'
The login name for the demo user. Enclosed in single quotes.
Password: 'password'
The password for the demo user. Enclosed in single quotes. Required.
Entity: module.Entity
Optional. The entity that generalizes System.User (e.g., Administration.Account). If the project has exactly one System.User subtype, this can be omitted and it will be auto-detected.
UserRoles: ( UserRole [, ...] )
The project-level user role names (unqualified) to assign to the demo user.

Examples

Create a demo user with auto-detected entity:

CREATE DEMO USER 'demo_admin' ( Password: 'Admin123!', UserRoles: (AppAdmin) );

Create a demo user with an explicit entity:

CREATE DEMO USER 'demo_admin' ( Password: 'Admin123!', Entity: Administration.Account, UserRoles: (AppAdmin) );

Create multiple demo users for different roles:

mdl 1;
CREATE DEMO USER 'admin' ( Password: '1', Entity: Administration.Account, UserRoles: (AppAdmin) );
CREATE DEMO USER 'user' ( Password: '1', Entity: Administration.Account, UserRoles: (AppUser) );
CREATE DEMO USER 'viewer' ( Password: '1', Entity: Administration.Account, UserRoles: (AppViewer) );

See Also

CREATE USER ROLE, GRANT

UPDATE SECURITY

Bring entity access rules back into sync with their domain model — the headless equivalent of Studio Pro’s Update security button in the domain model editor.

Syntax

UPDATE SECURITY;
UPDATE SECURITY <module>;
UPDATE SECURITY IN <module>;

With no module, every module in the project is reconciled. The two scoped forms are the same thing; IN is optional.

What it does

An access rule names the members it governs. When the entity gains an attribute or an association, every rule on it needs an entry for the new member — and when a member goes, its entries have to go with it. A rule that has fallen behind is what Mendix reports as:

[error] [CE0066] "Entity access is out of date. Please update security by
        clicking the 'Update security' button in the domain model editor."
        at Domain model of module 'MyModule'

UPDATE SECURITY adds what is missing, removes what is stale, and downgrades write rights on calculated attributes. It reports what it changed:

Reconciled 3 access rule(s) in module Administration

A project whose rules already cover their entities is not written to — the command says All entity access rules are up to date and leaves the model alone.

System is skipped: its entities are the platform’s and its access rules are not the project’s to rewrite. Naming it explicitly is an error rather than a silent no-op.

When you need it

Rarely, for models mxcli writes. Every mxcli write path reconciles as it writes — GRANT, ALTER ENTITY, CREATE ASSOCIATION, and the finalize step after any script — so an MDL script leaves the rules correct.

It is for a model that arrived from somewhere else:

  • a module imported or updated outside Studio Pro, whose author never pressed Update security, so the package ships rules that do not cover every member;
  • a hand-edited or merged .mpr;
  • a project last touched by a Mendix version whose conversion added members.

mxcli marketplace install and mxcli marketplace update run this for the module they copy in, and report the count — a transplant copies the incoming units verbatim, so without it the package’s rules reach the project unchecked (mendixlabs/mxcli#1085).

Examples

mdl 1;
-- After a headless module install or update
UPDATE SECURITY UserCommons;

-- Every module in the project
UPDATE SECURITY;

From the shell, without writing a script:

mxcli -p app.mpr -c "update security UserCommons"

Notes

  • Reconciling a marketplace module’s rules changes that module, so it shows up in mxcli diff-local and mxcli marketplace diff reads it as a local edit. That is unavoidable — it is the same change Studio Pro’s button makes — and it only happens when the rules were genuinely incomplete.
  • The rules of an entity whose generalization lives in another module cannot be fully checked here: those inherited members are neither added nor removed, but existing entries for them are preserved rather than pruned.

See also

Navigation Statements

Statements for configuring application navigation profiles, home pages, login pages, and menu structures.

Mendix applications can have multiple navigation profiles (Responsive, Tablet, Phone, NativePhone) that each define a home page, optional login page, optional 404 page, and a hierarchical menu tree. Navigation profiles control which page the user sees first and how they navigate through the application.

Statements

StatementDescription
CREATE NAVIGATIONCreate a navigation profile, or write the home pages, login page, menu and sync clauses it states
ALTER NAVIGATIONEdit one profile’s menu items and page clauses in place: insert, replace, move, drop, set
LIST NAVIGATIONDisplay navigation profiles, menus, and home page assignments
CREATE MENUCreate, alter, describe and drop standalone menu documents (Menus$MenuDocument)
StatementSyntax
Describe navigationDESCRIBE NAVIGATION [profile]

CREATE NAVIGATION

Synopsis

CREATE [ OR MODIFY ] NAVIGATION profile
    [ APP TITLE 'text' ]
    [ APP ICON { module.Collection.Image | NONE } ]
    HOME { PAGE module.PageName | MICROFLOW module.Flow | NANOFLOW module.Flow }
    [ HOME { PAGE | MICROFLOW | NANOFLOW } module.Name FOR UserRole ]
    [ LOGIN PAGE module.PageName ]
    [ NOT FOUND PAGE module.PageName ]
    [ ON SYNC ERROR { THROW | CONTINUE } ]
    [ PROGRESSIVE WEB APP [ ( Precaching: bool, InstallPrompt: bool ) ] | PROGRESSIVE WEB APP OFF ]
    [ SYNC (
        sync_rules
    ) ]
    [ {
        menu_items
    } ]

Description

Creates a navigation profile, or writes the clauses it states into an existing one. Each profile defines the home page, optional role-specific home pages, optional login page, optional 404 page, and a hierarchical menu structure.

Every clause is optional, and a clause the statement omits leaves the stored value alone. A statement that states only a menu keeps the profile’s home page, role-based home pages, login page and not-found page. A clause that is stated replaces what it names: the { } block replaces the whole menu, the SYNC ( ) block the whole sync list, and HOME … FOR Role the home page of that role only, so other roles keep theirs. To change one menu item, or to clear a role’s home page, the login page or the not-found page, use ALTER NAVIGATION, which edits the stored profile and touches nothing else.

CREATE OR REPLACE NAVIGATION is a deprecated alias of CREATE OR MODIFY NAVIGATION (MDL-DEPR001) with the same meaning.

The output of DESCRIBE NAVIGATION states every clause the profile has, so it can be edited and re-executed as it is.

Profile Types

Mendix supports the following navigation profile types:

ProfileDescription
ResponsiveWeb browser (desktop and mobile responsive)
TabletTablet-optimized web
PhonePhone-optimized web
ResponsiveOfflineResponsive web, offline-capable (PWA)
TabletOfflineTablet web, offline-capable (PWA)
PhoneOfflinePhone web, offline-capable (PWA)
NativePhoneNative mobile application

The web kinds are a closed set, and the profile is created if the project does not have it yet. The three offline kinds are the online names plus Offline, and each one needs a SYNC block to download anything.

Menu items form a hierarchy. They are the profile’s children, in { ... } after its clauses, like a page’s widgets. Top-level items appear in the main navigation bar. Nested submenus are created with the MENU 'label' { ... } syntax.

Each MENU ITEM specifies a label and, in its property list, an action and an icon: MENU ITEM 'Home' ( OnClick: SHOW PAGE M.Home, Icon: Atlas_Core.Atlas.home ). A child ends in ) or }, so no separator is written between items.

The old spelling — MENU ( MENU ITEM 'Home' PAGE M.Home; … ) — still parses and warns (MDL-DEPR121, MDL-DEPR122); mxcli fmt --upgrade rewrites it.

Parameters

profile

The navigation profile type — see Profile Types. One of Responsive, Tablet, Phone, ResponsiveOffline, TabletOffline, PhoneOffline or NativePhone.

HOME PAGE module.PageName

The default home page for the profile. The page must already exist. Omitting the clause keeps the stored home page. A web profile without a home page fails the build with CE0527.

HOME MICROFLOW module.Flow / HOME NANOFLOW module.Flow

A flow run as the home instead of a page: a microflow on a web profile, a nanoflow on a native one. Each is refused on the other kind of profile (HOME MICROFLOW on a native profile, which DESCRIBE used to print for its nanoflow home, still runs with a warning).

HOME PAGE module.PageName FOR UserRole

Optional role-specific home page. Users with this role see a different home page than the default. Multiple role-specific home pages can be specified. Each one replaces the stored home page of its role only, and a role the statement does not mention keeps its home page.

The role is a user role, written as a bare name (FOR Administrator). User roles are project-level and have no module part. A module-qualified name here — a module role, which often shares the name — produces a project Mendix cannot load: StorageLoadException: … is not a valid UserRoleIdentifier, raised before checking runs. mxcli check --references refuses it.

APP TITLE 'text'

Studio Pro’s Application title: the prefix of every browser tab, which reads Mendix - <page title> in a new project until it is set. The text is written as the title’s item in the project’s default language, as a menu caption is, and the title’s other languages are kept; translate it with CREATE OR MODIFY TRANSLATIONS without IN <Module>, since the navigation is a project-level document. Omitting the clause keeps the stored title. DESCRIBE NAVIGATION prints the stored title, Studio Pro’s default 'Mendix' included.

APP ICON module.Collection.Image / APP ICON NONE

Studio Pro’s Application icon, an image from an image collection, resolved like any image reference: mxcli check --references reports one that does not exist and exec refuses it before writing. It must be a square PNG at least 512 pixels wide; anything else fails mx check with CE6859, so exec refuses a stored image that is not one, naming its size. NONE clears it. Omitting the clause keeps the stored icon.

LOGIN PAGE module.PageName

Optional custom login page. A profile with no login page uses the default system login page. Omitting the clause keeps the stored login page.

NOT FOUND PAGE module.PageName

Optional custom 404 page shown when a requested page is not found. Omitting the clause keeps the stored page.

{ menu_items }

Optional menu structure. Contains MENU ITEM and nested MENU entries. An empty { } clears the menu; omitting the block leaves it alone.

A leaf menu item. OnClick is SHOW PAGE module.PageName, CALL MICROFLOW module.Microflow, CALL NANOFLOW module.Nanoflow, OPEN LINK 'url', CREATE OBJECT module.Entity [THEN SHOW PAGE module.PageName], SIGN OUT or NOTHING, with a button action’s WITH ( … ) settings; Icon is Module.Collection.icon, GLYPH n or IMAGE Module.Images.name. Both are optional. DESCRIBE prints every stored action this way, and flags what it cannot spell (a page title override, a link type other than Web), which a rewrite keeps while the item is unchanged.

A submenu containing nested menu items and/or further submenus.

Offline Synchronization

SYNC ( ... ) configures which entities an offline profile downloads. An offline profile downloads nothing without it — the app builds, routes and installs as a PWA, and shows an empty screen.

SYNC (
    SYNC Sales.Setting ONLINE;
    SYNC Sales.Order ALL;
    SYNC Sales.Trip WHERE [Distance > 0];
    SYNC Sales.Audit NEVER;
    SYNC Sales.Lookup NONE;
    SYNC Sales.Draft NONE PRESERVE DATA;
)
ModeMeaning
ONLINEFetched from the server; never held on the device
ALLEvery object downloaded
WHERE [ xpath ]Only the objects the XPath selects
NEVERNot synchronized
NONENot downloaded; anything already on the device is dropped
NONE PRESERVE DATANot downloaded; what is on the device stays

These are the values Mendix stores, not the captions Studio Pro shows in its Customize offline synchronization dialog: its “All Objects” is ALL and its “By XPath” is WHERE. A caption is refused rather than written.

WHERE implies the constrained mode rather than naming it, so a constraint without a mode and a mode without a constraint are both unspellable. The XPath goes in brackets and is taken verbatim — nothing inside is escaped. A quoted WHERE 'xpath' still parses, but every quote inside it must be doubled.

The block replaces the stored list, the way MENU replaces the menu. Omitting it leaves the stored configuration alone.

ON SYNC ERROR THROW | CONTINUE is Studio Pro’s “Throw error when server rejects objects during synchronization”, and defaults to THROW. It reuses the phrase MDL already has for failure handling — a microflow’s ON ERROR CONTINUE — rather than introducing a keyword of its own. Omitting the clause leaves the stored value alone, and DESCRIBE NAVIGATION emits it only when it is not the default.

PROGRESSIVE WEB APP is Studio Pro’s “Progressive web app” settings. A profile stores none until they are set, and an offline profile without them gets no service worker: the built index.js has "registerServiceWorker": false, and no page opens without a network, while check, exec and mx check all pass (mendixlabs/mxcli#1377). Precaching pre-loads the app’s pages and resources (default false); InstallPrompt allows the Add to home screen prompt (default true); a key the clause leaves out takes its default, so a bare PROGRESSIVE WEB APP writes the defaults, PROGRESSIVE WEB APP OFF stores none, and omitting the clause leaves the stored settings alone. DESCRIBE NAVIGATION emits it when settings are stored, naming only the keys that differ from the defaults, and CREATE NAVIGATION warns about an offline profile left without them.

CREATE OR MODIFY NAVIGATION PhoneOffline
    HOME PAGE Field.WorkOrder_List
    PROGRESSIVE WEB APP ( Precaching: true )
    SYNC ( SYNC Field.WorkOrder ALL; );

An entity’s compatibility mode flag has no MDL syntax. It is read, preserved across a rewrite, and reported by DESCRIBE NAVIGATION — never silently dropped.

Examples

Minimal navigation with just a home page:

CREATE OR MODIFY NAVIGATION Responsive
    HOME PAGE MyModule.Home_Web;

Full navigation with role-specific homes and menus:

CREATE OR MODIFY NAVIGATION Responsive
    APP TITLE 'Order Desk'
    APP ICON MyModule.Images.Logo
    HOME PAGE MyModule.Home_Web
    HOME PAGE MyModule.AdminHome FOR Administrator
    LOGIN PAGE Administration.Login
    NOT FOUND PAGE MyModule.Custom404
    {
        MENU ITEM 'Home' ( OnClick: SHOW PAGE MyModule.Home_Web )
        MENU 'Admin' {
            MENU ITEM 'Users' ( OnClick: SHOW PAGE Administration.Account_Overview )
            MENU ITEM 'Settings' ( OnClick: SHOW PAGE MyModule.Settings )
        }
        MENU ITEM 'About' ( OnClick: SHOW PAGE MyModule.About )
    };

Native mobile navigation. A native profile’s home is a page or a nanoflow (HOME NANOFLOW); mxcli writes its home pages and SYNC block only, and refuses a { } menu block (the bottom bar), LOGIN PAGE, NOT FOUND PAGE, ON SYNC ERROR, PROGRESSIVE WEB APP, APP TITLE and APP ICON on one — set those in Studio Pro. DESCRIBE lists the bottom bar as comments:

CREATE OR MODIFY NAVIGATION NativePhone
    HOME NANOFLOW Mobile.NAV_Home
    HOME PAGE Mobile.Dashboard FOR User;

See Also

ALTER NAVIGATION, LIST NAVIGATION

ALTER NAVIGATION

Synopsis

ALTER NAVIGATION profile {
    item_operation | page_clause_operation
    [ ... ]
};

-- item_operation
INSERT { BEFORE | AFTER | INTO } item { menu_items }
INSERT { menu_items }
REPLACE item WITH { menu_items }
MOVE item { BEFORE | AFTER | INTO } item
DROP item [, item ...]
SET ( [ Caption: 'text' ] [, OnClick: action | NOTHING ] [, Icon: icon | NONE ] ) ON item

-- page_clause_operation
SET HOME { PAGE | MICROFLOW } module.Name [ FOR UserRole ]
DROP HOME PAGE FOR UserRole
SET LOGIN PAGE module.Page
DROP LOGIN PAGE
SET NOT FOUND PAGE module.Page
DROP NOT FOUND PAGE

-- item
'caption' [ @n ] [ > 'caption' [ @n ] ... ]

Description

Edits one web navigation profile in place: its menu items at any depth, and its home, login and not-found pages. Where CREATE OR MODIFY NAVIGATION replaces the whole menu with the { } block it is given, ALTER NAVIGATION changes only what it names. Adding one item does not mean restating the menu, an item someone added in Studio Pro meanwhile is kept, and nothing the statement does not name is re-validated.

The operations apply in order to the stored profile, each one against the menu as the operations before it left it, so an item inserted by one operation can be the target of the next. The statement is written once: if any operation is refused, nothing is written.

The same item operations apply to a menu document with ALTER MENU.

Addressing an item

A menu item has no name, so it is addressed by the caption DESCRIBE NAVIGATION prints for it, written as a path from outer to inner:

AddressMeans
'Users'the item captioned Users, anywhere in the menu
'Admin' > 'Users'Users directly under Admin
'Settings' > 'Admin' > 'Users'the same, one level further out
'Users'@2the 2nd item captioned Users, in describe order
'Admin'@2 > 'Users'Users under the 2nd item captioned Admin
''@2the 2nd item with an empty caption (an icon-only item)
  • Captions compare exactly, case included, with the caption DESCRIBE prints — the project’s default language, as for a page’s texts.

  • A path matches as a suffix: the first segment matches at any depth, and each > is one direct parent/child step.

  • @n belongs to the segment it follows and picks the n-th of that segment’s matches in describe order.

  • An address that matches nothing, or several items, is refused. The error for an ambiguous address lists the shortest address of every candidate:

    menu item 'Users' is ambiguous: it matches 2 items — write one of these:
      'Admin' > 'Users'
      'Reports' > 'Users'
    
  • There are no positional addresses. LIST NAVIGATION MENU prints the address of every item.

Item operations

INSERT BEFORE | AFTER item { … }
Places the items as siblings of item.
INSERT INTO item { … }
Appends them as item’s last sub-items. item must have no action (Mendix CE0548); use SET ( OnClick: NOTHING ) ON item first.
INSERT { … }
Appends them to the top level. This is also how to add to an empty menu.
REPLACE item WITH { … }
Puts the items where item was; its sub-items go with it unless the new items state them again. A replacement keeps the other-language translations of a caption it does not change.
MOVE item BEFORE | AFTER | INTO item
Moves the stored item itself — its sub-items, every caption translation and any action MDL cannot spell go with it. Moving an item into its own sub-items is refused.
DROP item, …
Removes each item and its sub-items.
SET ( … ) ON item
Changes only the keys written. Caption: writes the project’s default language and keeps the other translations. OnClick: takes the action expression a menu item takes in CREATE NAVIGATION; NOTHING stores Studio Pro’s “Do nothing”, the only action an item with sub-items may have. Icon: takes an icon-collection name, GLYPH n or IMAGE module.Images.name, or NONE for no icon.

The { menu_items } of INSERT and REPLACE are written exactly as in CREATE NAVIGATION, so a line DESCRIBE NAVIGATION printed can be pasted in.

Page clause operations

Each is the CREATE NAVIGATION clause after SET or DROP.

OperationEffect
SET HOME PAGE M.P / SET HOME MICROFLOW M.Fthe default home
SET HOME PAGE M.P FOR Rolethat user role’s home; other roles keep theirs
DROP HOME PAGE FOR Roleremoves that role’s home, page or microflow
SET LOGIN PAGE M.P / DROP LOGIN PAGEsets or clears the login page
SET NOT FOUND PAGE M.P / DROP NOT FOUND PAGEsets or clears the fallback page

There is no DROP HOME PAGE without FOR: a web profile needs a default home page (Mendix CE0527), so SET HOME PAGE changes it instead. FOR takes a bare user role, as in CREATE NAVIGATION.

What is refused

  • an address that matches nothing or several items, or an @n beyond the matches;
  • INSERT INTO or MOVE … INTO an item with an action, and an OnClick: other than NOTHING on an item with sub-items (CE0548);
  • DROP HOME PAGE without FOR (CE0527), and DROP HOME PAGE FOR a role that has none;
  • a native profile — its bottom bar is not written by mxcli; set its home pages with CREATE OR MODIFY NAVIGATION;
  • a profile the project does not have — CREATE OR MODIFY NAVIGATION adds one.

mxcli check script.mdl -p app.mpr --references resolves every address against the stored menu with the same code exec runs and reports the same refusals.

Parameters

profile
A web navigation profile the project has: Responsive, Phone, Tablet or one of their Offline kinds.

Examples

Given the menu DESCRIBE NAVIGATION Responsive prints:

CREATE OR MODIFY NAVIGATION Responsive
    HOME PAGE Shop.Home_Web
    HOME PAGE Shop.AdminHome FOR Administrator
    LOGIN PAGE Administration.Login
    NOT FOUND PAGE Shop.NotFound
{
    MENU ITEM 'Home' ( OnClick: SHOW PAGE Shop.Home_Web )
    MENU 'Admin' {
        MENU ITEM 'Users' ( OnClick: SHOW PAGE Administration.Account_Overview )
        MENU ITEM 'Old import' ( OnClick: CALL MICROFLOW Shop.ACT_LegacyImport )
    }
    MENU 'Reports' {
        MENU ITEM 'Users' ( OnClick: SHOW PAGE Shop.Report_Users )
    }
    MENU ITEM 'Help' ( OnClick: OPEN LINK 'https://example.com/help' )
    MENU ITEM 'Log out' ( OnClick: SIGN OUT )
};

one statement makes several unrelated edits and touches nothing else:

ALTER NAVIGATION Responsive {
    INSERT AFTER 'Home' {
        MENU ITEM 'Orders' ( OnClick: SHOW PAGE Shop.Order_Overview )
    }
    INSERT INTO 'Admin' {
        MENU ITEM 'Audit log' ( OnClick: SHOW PAGE Shop.AuditLog_Overview )
    }
    SET ( Caption: 'User accounts' ) ON 'Admin' > 'Users';
    REPLACE 'Help' WITH {
        MENU 'Help' {
            MENU ITEM 'Documentation' ( OnClick: OPEN LINK 'https://example.com/docs' )
            MENU ITEM 'Contact' ( OnClick: SHOW PAGE Shop.Contact )
        }
    }
    MOVE 'Log out' AFTER 'Help';
    DROP 'Old import';
    SET LOGIN PAGE Shop.CustomLogin;
    DROP NOT FOUND PAGE;
};

A bare 'Users' in that statement would be refused: it matches two items.

See Also

CREATE NAVIGATION, LIST NAVIGATION, CREATE MENU

LIST NAVIGATION

Synopsis

LIST NAVIGATION
LIST NAVIGATION MENU [ profile ]
LIST NAVIGATION HOMES
DESCRIBE NAVIGATION [ profile ]

Description

Displays navigation configuration for the current project.

LIST NAVIGATION

Displays a summary of all configured navigation profiles, showing the profile type and whether a home page, login page, and menu are configured.

LIST NAVIGATION MENU

Displays the menu tree for one or all navigation profiles. When a profile is specified, only that profile’s menu is shown. Without a profile, all profiles’ menus are displayed.

The menu is listed as a table, one row per item in the order DESCRIBE prints them: the profile, the caption indented by depth, the action’s target, and the Address — the shortest unique caption path that ALTER NAVIGATION takes to reach the item ('Admin' > 'Users', or 'Home'@2 where captions repeat). A native profile’s bottom bar has no address, because ALTER NAVIGATION does not edit it.

LIST NAVIGATION HOMES

Displays home page assignments across all navigation profiles, including role-specific overrides.

DESCRIBE NAVIGATION

Outputs the full MDL representation of one or all navigation profiles. The output is round-trippable – it can be re-executed with CREATE OR REPLACE NAVIGATION to recreate the profile.

Parameters

profile
Optional. One of: Responsive, Tablet, Phone, NativePhone. When omitted, all profiles are shown.

Examples

Show a summary of all profiles:

LIST NAVIGATION;

Show the menu tree for the responsive profile:

LIST NAVIGATION MENU Responsive;

Show all home page assignments:

LIST NAVIGATION HOMES;

Export the responsive profile as MDL:

DESCRIBE NAVIGATION Responsive;

See Also

CREATE NAVIGATION, ALTER NAVIGATION

CREATE MENU

Synopsis

CREATE [ OR MODIFY ] MENU module.name [ FOLDER 'folder_path' ] { menu_item [ menu_item ... ] }
ALTER MENU module.name { item_operation [ item_operation ... ] }
DESCRIBE MENU module.name
DROP MENU module.name

Where each menu_item is one of:

MENU ITEM 'caption' [ ( [ OnClick: action ] [, Icon: icon ] ) ]
MENU 'caption' [ ( Icon: icon ) ] { nested_items }

and each item_operation is one of the item operations of ALTER NAVIGATION — INSERT, REPLACE, MOVE, DROP, SET ( … ) ON — with items addressed by caption path ('Group' > 'Item').

Description

Manages standalone menu documents (Menus$MenuDocument) — reusable menus that a menu widget on a page points at. Atlas_Core ships two of them, Phone_Menu and Tablet_Menu.

A menu document is not the menu inside a navigation profile, although the two are easy to confuse: both are built from the same menu items, which is why the item syntax here is identical to the { ... } menu block of CREATE NAVIGATION. They differ in where they live and how they are read:

Profile menuMenu document
Lives ina navigation profileits own document
Used bythe app’s navigationa menu widget you place on a page
Read withLIST NAVIGATION MENUDESCRIBE MENU module.name
Written withCREATE OR MODIFY NAVIGATIONCREATE OR MODIFY MENU
One item at a timeALTER NAVIGATIONALTER MENU

OR MODIFY replaces the item list wholesale, exactly as the { } block of CREATE OR MODIFY NAVIGATION does: the list you give is the document’s complete contents, so an omitted item is a removed item. The document’s identity, container and export level are preserved, so menu widgets pointing at it keep working.

ALTER MENU edits the stored items instead: it inserts, replaces, moves, drops or changes the items it names and keeps every other item exactly as stored. It takes the item operations of ALTER NAVIGATION — no page clauses, since a menu document has none:

ALTER MENU MyModule.Main_Menu {
    INSERT AFTER 'Home' { MENU ITEM 'Orders' ( OnClick: SHOW PAGE MyModule.Order_Overview ) }
    MOVE 'Run' INTO 'Admin';
    SET ( Caption: 'Accounts and roles' ) ON 'Admin' > 'Accounts';
    DROP 'Plain';
};

DESCRIBE MENU emits a re-executable CREATE OR MODIFY MENU statement, so describe → edit → exec is the normal editing loop, and describe → exec → describe is a fixed point.

Parameters

module.name
Qualified name of the menu document.
FOLDER 'folder_path'
Optional. Places the document in the named module folder, creating missing folders in the path. On CREATE OR MODIFY this moves an existing document; omitting the clause leaves placement alone rather than returning the document to the module root. See MOVE.
'caption'
The item’s label, in single quotes.
OnClick:
Optional action run when the item is clicked, in the words a page action uses: show page M.P, call microflow M.F, call nanoflow M.N, open link 'https://…', create object M.E [then show page M.P], sign out or nothing — the action expression a button’s Action: takes, with its with ( … ) settings (progress bar and message, confirmation, asynchronous, form validations, disabled during action). Save, delete, close page and complete task are refused: a menu item has no object to act on. An item without one is inert (stored as Forms$NoAction), which is normal for an item that only groups sub-items. A page title override or a link type other than Web has no spelling; describe flags it, and a rewrite keeps it while the item’s caption and action are unchanged.
Icon:
Optional icon, given as a qualified name into an icon collection — not a string. Hyphenated segments are double-quoted: Atlas_Core.Atlas."layout-2". glyph <code> and image Module.Images.name write the other two icon kinds.

The items are children, in { } with no separator between them (R2). The old spelling — items in ( ), each ending in ;, with PAGE/MICROFLOW/ICON clauses after the caption — still parses and warns (MDL-DEPR121, MDL-DEPR122); mxcli fmt --upgrade rewrites it.

Examples

Create a menu with a nested sub-menu:

CREATE MENU MyModule.Main_Menu {
  menu item 'Home' ( OnClick: show page MyModule.Home_Web, Icon: Atlas_Core.Atlas.home )
  menu item 'Run' ( OnClick: call microflow MyModule.DoThing )
  menu 'Admin' {
    menu item 'Accounts' ( OnClick: show page Administration.Account_Overview )
  }
  menu item 'Plain'
};

Replace its contents (the two omitted items are removed):

CREATE OR MODIFY MENU MyModule.Main_Menu {
  menu item 'Home' ( OnClick: show page MyModule.Home_Web, Icon: Atlas_Core.Atlas.home )
  menu item 'Run' ( OnClick: call microflow MyModule.DoThing )
};

Read one back — including Atlas’s own:

DESCRIBE MENU Atlas_Core.Phone_Menu;

The type is auto-detected too, so the bare form works:

DESCRIBE Atlas_Core.Phone_Menu;

From the command line:

mxcli describe menu Atlas_Core.Phone_Menu -p app.mpr

Notes

A menu item cannot open a page that takes a required parameter. There is nowhere to supply the argument, and Mendix rejects the model with CE1571 (“No argument has been selected for parameter …”) reported against Menu item. Point the item at a parameterless page, or call a microflow that opens the page with its argument.

All three icon kinds round-trip. Icon: Module.Collection.name writes an icon-collection icon, Icon: GLYPH n a glyph and Icon: IMAGE Module.Images.name an image icon, and DESCRIBE prints each the same way. Only an icon it cannot rebuild — a glyph stored without a code, or an icon type mxcli does not know — is reported on its own comment line rather than dropped silently:

-- icon a numeric glyph code (Forms$GlyphIcon) is not reproducible by CREATE MENU;
-- set it in Studio Pro

See Also

Workflow Statements

Statements for creating, inspecting, and dropping workflows.

Mendix workflows model long-running business processes with user tasks, decisions, parallel splits, and other activity types. A workflow is parameterized by a context entity that carries the data flowing through the process.

Statements

StatementDescription
CREATE WORKFLOWDefine a workflow with activities, user tasks, decisions, and parallel paths
DROP WORKFLOWRemove a workflow from the project
StatementSyntax
Show workflowsLIST WORKFLOWS [IN module]
Describe workflowDESCRIBE WORKFLOW module.Name

CREATE WORKFLOW

Synopsis

CREATE [ OR MODIFY ] WORKFLOW module.Name
    PARAMETER $Variable: module.Entity
    [ OVERVIEW PAGE module.PageName ]
    [ DISPLAY 'caption' [ WITH ( {1} = expression, ... ) ] ]
    [ DESCRIPTION 'text' [ WITH ( {1} = expression, ... ) ] ]
BEGIN
    activities
END WORKFLOW

Description

Creates a workflow that models a long-running business process. Each workflow has a context parameter (an entity that carries the data through the process) and a sequence of activities.

The OR MODIFY option updates an existing workflow if one exists with the same name, or creates a new one if it does not.

Activity Types

The following activity types can appear inside a workflow body:

USER TASK
An activity that requires human action. A user task displays a page to an assigned user and waits for them to select an outcome. Supports targeting by microflow or XPath to determine the assignee.
USER TASK name 'caption'
    [ PAGE module.PageName ]
    [ TARGETING MICROFLOW module.MicroflowName ]
    [ TARGETING [ USERS | GROUPS ] XPATH [ xpath ] ]
    [ ENTITY module.Entity ]
    [ DUE DATE expression ]
    [ DESCRIPTION 'text' ]
    OUTCOMES 'OutcomeName' { activities } [ 'OutcomeName' { activities } ... ]

The targeting XPath is written in literal brackets, like every XPath in MDL, so quotes inside it are written once: TARGETING USERS XPATH [System.UserRoles = '[%UserRole_Manager%]']. The quoted form XPATH '[...]' still parses and warns MDL-DEPR031; mxcli fmt --upgrade rewrites it.

MULTI USER TASK
Same as USER TASK but assigned to multiple users. Uses the same syntax with MULTI USER TASK instead of USER TASK.
CALL MICROFLOW
Calls a microflow as part of the workflow. The microflow can return a Boolean or enumeration to drive conditional outcomes.
CALL MICROFLOW module.Name [ 'caption' ]
    [ WITH ( Parameter = expression [, ...] ) ]
    [ OUTCOMES 'OutcomeName' { activities } ... ]
CALL WORKFLOW
Calls a sub-workflow.
CALL WORKFLOW module.Name [ 'caption' ]
    [ WITH ( Parameter = expression [, ...] ) ]
DECISION
A conditional branch based on an expression. Each outcome maps to a different execution path.
DECISION [ name ] expression [ COMMENT 'caption' ]
    OUTCOMES TRUE -> { activities } FALSE -> { activities }

Every workflow expression — a decision’s condition, a timer’s delay, a due date — is written bare: DECISION $Context/Total > 1000, DUE DATE addDays([%CurrentDateTime%], 3). The string form (DECISION '$Context/Total > 1000') still parses and warns MDL-DEPR080; mxcli fmt --upgrade rewrites it.

PARALLEL SPLIT
Splits the workflow into parallel paths that execute concurrently and rejoin before continuing.
PARALLEL SPLIT [ 'caption' ]
    PATH 1 { activities }
    PATH 2 { activities }
    [ PATH n { activities } ... ]
JUMP TO
Unconditionally jumps to a named activity earlier in the workflow, creating a loop.
JUMP TO activity_name [ 'caption' ]
WAIT FOR TIMER
Pauses the workflow until a timer expression elapses.
WAIT FOR TIMER [ name ] [ datetime_expression ] [ COMMENT 'caption' ]
WAIT FOR NOTIFICATION
Pauses the workflow until an external notification resumes it.
WAIT FOR NOTIFICATION
END
Explicitly terminates a workflow path.
END

Boundary Events

User tasks, call microflow activities, and wait-for-notification activities support boundary events that trigger alternative flows when a timer expires:

USER TASK name 'caption'
    PAGE module.Page
    OUTCOMES 'Done' { }
    BOUNDARY EVENT INTERRUPTING TIMER addHours([%CurrentDateTime%], 1) CAPTION 'Escalate' { activities }
    BOUNDARY EVENT NON INTERRUPTING TIMER addMinutes([%CurrentDateTime%], 30) { activities }
    BOUNDARY EVENT NON INTERRUPTING TIMER addDays([%CurrentDateTime%], 1) REPEAT EVERY 1 DAY AT MOST 5 TIMES { activities }

REPEAT EVERY <n> <unit> AT MOST <m> TIMES makes a non-interrupting timer a repeating reminder: it fires at the delay, then every <n> minutes, hours, days, weeks or months, <m> times in all (the first firing counts, so <m> is at least 2). A timer-started event sub-process takes the same clause on Mendix 11.15 and later.

CAPTION '<caption>' after the delay is the text Studio Pro shows on the timer; it is optional.

Parameters

module.Name
Qualified name of the workflow (Module.WorkflowName).
PARAMETER $Variable: module.Entity
The context parameter. The variable name (prefixed with $) and entity type define the data that flows through the workflow. The entity must already exist.
OVERVIEW PAGE module.PageName
Optional overview page displayed when viewing workflow instances.
DISPLAY 'caption'
Optional display name shown in the workflow overview.
DESCRIPTION 'text'
Optional description of the workflow’s purpose.
WITH ( {1} = expression, ... )
Fills the {1}, {2}, … placeholders of DISPLAY or DESCRIPTION from expressions over $WorkflowContext, as a log message’s template does: DISPLAY 'Approval for {1}' WITH ({1} = $WorkflowContext/Title). A user task’s TASK NAME '…' (what the task inbox shows; the caption when omitted) and DESCRIPTION '…' take the same clause, and may also use $WorkflowInstance. A placeholder no parameter fills is CE0720, which mxcli check reports as MDL-WF21.

Examples

Simple approval workflow:

CREATE WORKFLOW HR.ApprovalFlow
    PARAMETER $Context: HR.LeaveRequest
    OVERVIEW PAGE HR.WorkflowOverview
BEGIN
    USER TASK ReviewTask 'Review the request'
        PAGE HR.ReviewPage
        OUTCOMES 'Approve' { } 'Reject' { };
END WORKFLOW;

Workflow with decision and parallel paths:

CREATE WORKFLOW Sales.OrderProcessing
    PARAMETER $Context: Sales.Order
BEGIN
    CALL MICROFLOW Sales.ACT_ValidateOrder 'Validate'
        OUTCOMES 'True' {
            PARALLEL SPLIT
                PATH 1 {
                    CALL MICROFLOW Sales.ACT_ReserveInventory;
                }
                PATH 2 {
                    CALL MICROFLOW Sales.ACT_ProcessPayment;
                };

            USER TASK ShipTask 'Arrange shipping'
                PAGE Sales.ShippingPage
                TARGETING MICROFLOW Sales.ACT_GetWarehouseStaff
                OUTCOMES 'Shipped' { };
        }
        'False' {
            CALL MICROFLOW Sales.ACT_NotifyCustomer;
        };
END WORKFLOW;

Workflow with a timer boundary event:

CREATE WORKFLOW Support.TicketEscalation
    PARAMETER $Context: Support.Ticket
BEGIN
    USER TASK AssignTask 'Handle ticket'
        PAGE Support.TicketPage
        OUTCOMES 'Resolved' { }
        BOUNDARY EVENT INTERRUPTING TIMER addHours([%CurrentDateTime%], 24) {
            CALL MICROFLOW Support.ACT_EscalateTicket;
        };
END WORKFLOW;

See Also

DROP WORKFLOW

DROP WORKFLOW

Synopsis

DROP WORKFLOW module.Name;

Description

Removes a workflow from the project. The workflow is identified by its qualified name. If the workflow does not exist, an error is returned.

Any references to the dropped workflow (e.g., CALL WORKFLOW activities in other workflows, or navigation entries) will become broken and should be updated or removed.

Parameters

module.Name
The qualified name of the workflow to drop (Module.WorkflowName).

Examples

DROP WORKFLOW HR.ApprovalFlow;

See Also

CREATE WORKFLOW

OData and Integration Statements

Statements for managing OData services, external entities, and browsing service contracts.

Mendix supports consuming and publishing OData services. Consumed services (OData clients) connect to remote OData endpoints and make external entity types available for use in your application. Published services expose your domain model entities as OData endpoints for other applications to consume.

OData Client Statements

StatementDescription
CREATE CONSUMED ODATA SERVICECreate a consumed OData service (auto-fetches $metadata)
ALTER CONSUMED ODATA SERVICEModify OData client properties
DROP CONSUMED ODATA SERVICERemove a consumed OData service

OData Service Statements (Published)

StatementDescription
CREATE PUBLISHED ODATA SERVICEPublish entities as an OData endpoint
ALTER PUBLISHED ODATA SERVICEModify published service properties
DROP PUBLISHED ODATA SERVICERemove a published OData service

External Entity Statements

StatementDescription
CREATE EXTERNAL ENTITYImport an entity from a consumed OData service
CREATE EXTERNAL ENTITIESBulk-create external entities from cached $metadata

CREATE EXTERNAL ENTITIES

Bulk-create external entities from a consumed OData service’s cached $metadata.

mdl 1;
-- Create all entity types from the contract
CREATE EXTERNAL ENTITIES FROM Module.Service;

-- Create into a specific module
CREATE EXTERNAL ENTITIES FROM Module.Service INTO TargetModule;

-- Filter to specific entities
CREATE EXTERNAL ENTITIES FROM Module.Service ENTITIES (Customer, Order);

-- Idempotent — update existing entities
CREATE OR MODIFY EXTERNAL ENTITIES FROM Module.Service;

Each entity is named after its OData entity type, as Studio Pro names it: an entity type Part exposed as entity set Parts becomes Module.Part. The entity set is kept as the entity’s remote entity set. A re-import finds an entity by the service and entity set it maps first, so an entity that an earlier mxcli version named after the set (Module.Parts) is updated in place and keeps its name.

Complex types are flattened

The Mendix domain model has no complex types. A property typed as an OData ComplexType is imported as one attribute per leaf — the same thing Studio Pro does — named <property>_<leaf> and read over the OData path <property>/<leaf>:

$metadataMendix attributeRemote name
MaxQty of type Shared.Uom.Quantity { UoMNId, QuantityValue }MaxQty_UoMNId, MaxQty_QuantityValueMaxQty/UoMNId, MaxQty/QuantityValue

The complex type may live in any Schema in the document — it is resolved by qualified name, so two namespaces may each declare a Quantity.

Three properties of a complex type are not importable. Each is reported by name and reason rather than dropped, so an import that loses something says so:

  • Inherited properties. Only a complex type’s own properties are imported. Where AirportLocation derives from Location, the inherited Address is not reachable through it (CE6615) — though the same Address is imported normally through a property typed Location directly.
  • Leaves Mendix cannot represent, such as Edm.GeographyPoint (CE6622).
  • A complex type nested in a complex type — flattening is one level deep.

Two more consequences:

  • Flattened attributes are read-only. Mendix treats an external entity that contains them as readable and deletable only, whatever the entity set’s InsertRestrictions / UpdateRestrictions say. Marking them creatable or updatable is CE6630.
  • They are filterable and sortable only where their entity is — that is, on an entity backed by an entity set. On a derived or contained type they are neither. CE6630 fires in both directions here, so this follows the contract rather than a fixed answer.

Each navigation property whose target is also imported becomes an association. A navigation property with a Partner is one end of a bidirectional relationship, and Mendix does not accept its ends as separate associations (CE6618 “Navigation ‘…’ is not available in the OData service”):

NavigationAssociation
No PartnerOne per navigation property — Reference, or ReferenceSet for a collection
One-to-many pair (Lines / ParentBuffer)One Reference from the single-valued end, naming both navigation properties
Many-to-many or one-to-one pairNone. Mendix offers no such navigation; the import lists the pair as not imported

A re-import of a module that an earlier mxcli version imported the old way lists the associations that fail with CE6618 as drop association …; lines. Run them, then run the import again.

Calling an External Action

$Response = call external action Odata.OrderService.CreateBuffer(
  command = $Cmd include associations (Odata.Quantity (Odata.Unit)),
  additional = nothing
);

include associations ( … ) after an argument is Studio Pro’s Associations to include: the associations whose objects are serialised into the OData request with that argument. It is a tree. A name followed by ( … ) lists the associations included under it, reached from the entity at its far end. Without the clause, a sub-object linked to the argument is not sent, and nothing fails until the service receives the request.

  • Which end of each association the object is on is worked out from the domain model, so the clause names only the associations.
  • A self-association is refused, because its direction cannot be derived. Tick it in Studio Pro instead. describe prints a tree that holds one as a comment.
  • include associations () removes them all. To remove some, restate the tree without them. The clause is always the whole tree.
  • Left out on create or modify, the stored tree is kept, and check -p and exec warn with MDL-ODATA10. Deleting the clause does not remove the associations.
  • describe microflow prints the clause, so describe → exec restates the tree.
  • A non-empty clause needs Mendix 10.11 or later.

Contract Browsing Statements

Browse available assets from cached service contracts without network access.

StatementDescription
LIST CONTRACT ENTITIES FROM Module.ServiceList entity types from cached $metadata
LIST CONTRACT ACTIONS FROM Module.ServiceList actions/functions from cached $metadata
DESCRIBE CONTRACT ENTITY Module.Service.EntityShow entity properties, types, keys
DESCRIBE CONTRACT ENTITY Module.Service.Entity FORMAT mdlGenerate CREATE EXTERNAL ENTITY
DESCRIBE CONTRACT ACTION Module.Service.ActionShow action parameters and return type
LIST CONTRACT CHANNELS FROM Module.ServiceList channels from cached AsyncAPI
LIST CONTRACT MESSAGES FROM Module.ServiceList messages from cached AsyncAPI
DESCRIBE CONTRACT MESSAGE Module.Service.MessageShow message payload properties
StatementSyntax
Show consumed odata servicesLIST CONSUMED ODATA SERVICES [IN module]
Show published odata servicesLIST PUBLISHED ODATA SERVICES [IN module]
Show external entitiesLIST EXTERNAL ENTITIES [IN module]
Show external actionsLIST EXTERNAL ACTIONS [IN module]
Describe consumed odata serviceDESCRIBE CONSUMED ODATA SERVICE Module.Name
Describe published odata serviceDESCRIBE PUBLISHED ODATA SERVICE Module.Name
Describe external entityDESCRIBE EXTERNAL ENTITY Module.Name

Catalog Tables

After REFRESH CATALOG, the following tables are available for SQL queries:

TableContents
CATALOG.CONSUMED_ODATA_SERVICESConsumed OData services (old name: ODATA_CLIENTS)
CATALOG.PUBLISHED_ODATA_SERVICESPublished OData services (old name: ODATA_SERVICES)
CATALOG.EXTERNAL_ENTITIESImported external entities
CATALOG.EXTERNAL_ACTIONSExternal actions used in microflows
CATALOG.CONTRACT_ENTITIESEntity types from cached $metadata
CATALOG.CONTRACT_ACTIONSActions/functions from cached $metadata
CATALOG.CONTRACT_MESSAGESMessages from cached AsyncAPI

Example: Find available entities not yet imported

SELECT ce.EntityName, ce.ServiceQualifiedName, ce.PropertyCount
FROM CATALOG.CONTRACT_ENTITIES ce
LEFT JOIN CATALOG.EXTERNAL_ENTITIES ee
  ON ce.ServiceQualifiedName = ee.ServiceName
  AND ce.EntityName = ee.RemoteName
WHERE ee.Id IS NULL;

Integration Statements

Statements for managing JSON structures, import mappings, export mappings, and data transformers.

These document types support moving data between Mendix entities and external data formats (JSON, XML). They are typically used together: a JSON structure defines the external shape, import and export mappings translate between that shape and Mendix entities, and data transformers reshape raw data before it enters a mapping.

Statements

StatementDescription
CREATE JSON STRUCTUREDefine the shape of a JSON document from a sample snippet
CREATE MESSAGE DEFINITION COLLECTIONDefine a mapping source as a selection over the domain model
CREATE MESSAGE DEFINITIONOne message definition as its own document (Mendix 11.15+)
CREATE IMPORT MAPPINGMap incoming JSON/XML to Mendix entities
CREATE EXPORT MAPPINGMap Mendix entities to outgoing JSON/XML
CREATE DATA TRANSFORMERTransform raw JSON or XML with JSLT/XSLT steps
StatementSyntax
List JSON structuresLIST JSON STRUCTURES [IN module]
List import mappingsLIST IMPORT MAPPINGS [IN module] or LIST IMPORT MAPPINGS [IN module]
List export mappingsLIST EXPORT MAPPINGS [IN module] or LIST EXPORT MAPPINGS [IN module]
List data transformersLIST DATA TRANSFORMERS [IN module]
Describe JSON structureDESCRIBE JSON STRUCTURE module.Name
Describe import mappingDESCRIBE IMPORT MAPPING module.Name
Describe export mappingDESCRIBE EXPORT MAPPING module.Name
Describe data transformerDESCRIBE DATA TRANSFORMER module.Name

Drop Statements

mdl 1;
DROP JSON STRUCTURE module.Name;
DROP IMPORT MAPPING module.Name;
DROP EXPORT MAPPING module.Name;
DROP DATA TRANSFORMER module.Name;

CREATE JSON STRUCTURE

Synopsis

[ /** description */ ]
CREATE [ OR MODIFY ] JSON STRUCTURE module.Name
    [ FOLDER 'folder/path' ]
    SAMPLE 'json_sample'
    [ CUSTOM_NAME_MAP ( 'jsonKey' AS 'AttributeName' [, ...] ) ]

Description

Creates a JSON structure document from a representative JSON sample. SNIPPET is the old keyword for SAMPLE: it still parses and warns MDL-DEPR132. The sample is stored verbatim and Mendix uses its shape to define attribute types and nesting when you create import or export mappings against it.

The structure represents one concrete JSON document shape. Arrays within the sample define the repeating item structure. Nested objects define sub-entity structures.

If OR MODIFY is specified and the JSON structure already exists, it is updated in-place preserving its UUID so that import/export mappings that reference it remain valid. OR REPLACE is also accepted as a synonym.

The optional CUSTOM_NAME_MAP clause overrides the attribute names generated from JSON keys. This is useful when JSON keys contain characters that are not valid Mendix attribute names, or when you want a more descriptive name.

Parameters

module.Name
The qualified name of the JSON structure.
FOLDER 'folder/path'
Optional. Places the document in the specified Studio Pro folder (forward-slash separated), creating missing folders in the path. On CREATE OR MODIFY this moves an existing document; omitting the clause leaves placement alone rather than returning the document to the module root. See MOVE.
/** description */
Optional. The documentation of the JSON structure document, as a doc comment before the statement. The COMMENT 'description' clause is its deprecated alias (MDL-DEPR100).
SAMPLE 'json_sample'
A representative JSON document. Must be a valid JSON string. The sample defines field names and types. Multi-line snippets can use $$...$$ quoting.
CUSTOM_NAME_MAP ( 'jsonKey' AS 'AttributeName' )
Optional. Renames individual JSON keys to the specified attribute names. Useful for keys that are reserved words or contain special characters.

Examples

Flat JSON object

CREATE JSON STRUCTURE MyModule.JSON_Pet
    SAMPLE '{"id": 1, "name": "Fido", "status": "available"}';

Nested JSON with array

CREATE JSON STRUCTURE MyModule.JSON_Order
    SAMPLE '{
        "orderId": 100,
        "customer": {"name": "Alice", "email": "alice@example.com"},
        "items": [{"sku": "A1", "quantity": 2, "price": 9.99}]
    }';

With custom name mapping

CREATE JSON STRUCTURE MyModule.JSON_WeatherResponse
    SAMPLE '{"current_temperature": 12.8, "wind_speed_10m": 18.3}'
    CUSTOM_NAME_MAP (
        'current_temperature' AS 'Temperature',
        'wind_speed_10m'      AS 'WindSpeed'
    );

Naming an array’s item element

An array’s item is the anonymous […] entry, so it has no JSON key and the plain form cannot reach it. ITEM OF addresses it by the array’s key:

CREATE JSON STRUCTURE MyModule.JSON_Invoice
    SAMPLE '{"lines": [{"sku": "A1"}], "tags": ["urgent"]}'
    CUSTOM NAME MAP (
        'lines'         AS 'OrderLines',
        ITEM OF 'lines' AS 'OrderLine',
        ITEM OF 'tags'  AS 'Tag'
    );

The two clauses are independent: naming an item does not require renaming its array. ITEM OF also names a primitive array’s wrapper element, which is that array’s item.

A root-level array has no key either — ITEM OF 'Root' names its item:

CREATE JSON STRUCTURE MyModule.JSON_Feed
    SAMPLE '[{"id": 1}]'
    CUSTOM NAME MAP (ITEM OF 'Root' AS 'Entry');

Left unnamed, an item keeps its generated name (<Array>Item, the singular for a primitive array’s wrapper, JsonObject at the root).

An entry naming a key the snippet does not contain is an error (MDL-JSON01), as is ITEM OF on a key that is not an array (MDL-JSON02) — previously such an entry applied to nothing and reported nothing.

Idempotent replacement

CREATE OR MODIFY JSON STRUCTURE MyModule.JSON_Pet
    SAMPLE '{"id": 1, "name": "Fido", "status": "available", "tags": []}';

See Also

CREATE IMPORT MAPPING, CREATE EXPORT MAPPING

CREATE MESSAGE DEFINITION COLLECTION

Synopsis

CREATE [ OR MODIFY ] MESSAGE DEFINITION COLLECTION module.Name
    [ FOLDER 'folder_path' ]
(
    DEFINITION DefName FOR module.Entity [ AS 'ExposedName' ] (
        AttributeName [ AS 'ExposedName' ] [ EXAMPLE 'text' ],
        module.Association/module.TargetEntity [ AS 'ExposedName' ] ( ... ),
        ...
    ),
    ...
);

Mendix 10 to 11.14. Mendix 11.15 removed collections: each definition is its own document, created with CREATE MESSAGE DEFINITION. On an 11.15 project the collection statements are refused with a hint to use that form.

Description

Creates a message definition collection — one of the four sources an import or export mapping can be bound to, and the only one besides a JSON structure that MDL can create.

Unlike an XML schema (which holds an imported .xsd) or an imported web service (which holds a WSDL), a message definition holds nothing external. It is a selection over the domain model: every element names an entity, an attribute or an association. A mapping then binds to module.Collection.Definition.

Members

A bare name is an attribute. An Association/TargetEntity pair is an association with its own member list — the same discriminator import and export mappings use.

Naming the association’s target entity is required, and not decoration. The stored cardinality is derived from the direction of traversal and the association’s type together:

traversalReferenceReferenceSet
from the association’s FROM entity (following the reference)a single objecta list
from its TO entity (the reverse)a lista list

So a Reference gives a single object one way and a list the other, while a ReferenceSet is a list in both directions — a set is many at both ends.

An association that connects the two entities in neither direction is refused rather than guessed at. The two halves of the rule fail differently if you do get one wrong, which is useful when diagnosing: a wrong direction builds cleanly and silently exposes a list as a single object, while a wrong type is caught by mxbuild as CE6524 on the definition (“The occurrence of ‘…’ has changed”) plus CE0295 on any mapping element bound to it.

Inherited attributes are named exactly like the entity’s own; mxcli resolves each to the entity that declares it, which is what Mendix stores.

What is derived

Almost everything. A statement says only what a person chooses — the collection’s name, each definition’s name, its root entity, the members, and the occasional rename. Occurrence bounds, nillability, precision, element types, paths, item names and the primitive type all come from the domain model.

EXAMPLE 'text' sets an element’s sample value, the one other authored field.

What is not guessed

Studio Pro pluralises a repeating element’s exposed name (Order → Orders). mxcli defaults to the entity’s own name and lets AS 'Orders' say otherwise: reproducing English inflection needs -y → -ies and an already-plural detector, and a name the author writes beats one a heuristic guesses.

Examples

mdl 1;
CREATE MESSAGE DEFINITION COLLECTION Sales.MD_Order
    FOLDER 'Messages'
{
    DEFINITION OrderMessage FOR Sales.Order AS 'Orders' {
        OrderId,
        TotalAmount AS 'Total',
        Sales.OrderLine_Order/Sales.OrderLine AS 'Lines' { Sku, Quantity },
        Sales.Order_Customer/Sales.Customer { FirstName, LastName }
    },
    DEFINITION CustomerOrders FOR Sales.Customer AS 'Customers' {
        FirstName,
        Sales.Order_Customer/Sales.Order AS 'Orders' { OrderId }
    }
};

CREATE IMPORT MAPPING Sales.IMM_Order
    WITH MESSAGE DEFINITION Sales.MD_Order.OrderMessage
{ create Sales.Order { OrderId = OrderId } };

Editing without restating

Definitions nest deeply, so a whole-document rewrite is a poor tool for “expose one more attribute”.

mdl 1;
ALTER MESSAGE DEFINITION Sales.MD_Order.OrderMessage ADD MEMBER TotalAmount;
ALTER MESSAGE DEFINITION Sales.MD_Order.OrderMessage ADD MEMBER LastName IN Customer;
ALTER MESSAGE DEFINITION Sales.MD_Order.OrderMessage SET MEMBER TotalAmount AS 'GrandTotal';
ALTER MESSAGE DEFINITION Sales.MD_Order.OrderMessage DROP MEMBER Sku IN Lines;

ALTER MESSAGE DEFINITION COLLECTION Sales.MD_Order ADD DEFINITION Line FOR Sales.Line { Sku };
ALTER MESSAGE DEFINITION COLLECTION Sales.MD_Order RENAME DEFINITION Line TO OrderLine;
ALTER MESSAGE DEFINITION COLLECTION Sales.MD_Order DROP DEFINITION IF EXISTS OrderLine;

The definition is addressed as module.Collection.Definition — the same three-part reference WITH MESSAGE DEFINITION takes.

IN <path> reaches a nested member, written in exposed names. SET changes only the exposed name; it is not a model rename, which is why the verb is not RENAME.

Dropping or renaming a definition a mapping still references is refused, naming the mappings.

Other statements

LIST MESSAGE DEFINITION COLLECTIONS [ IN module ];
DESCRIBE MESSAGE DEFINITION COLLECTION module.Name;
DROP MESSAGE DEFINITION COLLECTION module.Name;

DESCRIBE emits re-executable MDL. OR MODIFY preserves the document UUID, so mappings bound to it keep resolving.

See Also

CREATE IMPORT MAPPING, CREATE EXPORT MAPPING, CREATE JSON STRUCTURE

CREATE MESSAGE DEFINITION

Synopsis

CREATE [ OR MODIFY ] MESSAGE DEFINITION module.Name
    [ FOLDER 'folder_path' ]
    FOR module.Entity [ AS 'ExposedName' ] {
        AttributeName [ AS 'ExposedName' ] [ EXAMPLE 'text' ],
        module.Association/module.TargetEntity [ AS 'ExposedName' ] { ... },
        ...
    };

Mendix 11.15 and later. Below 11.15 the statement is refused; a message definition is then an entry of a message definition collection.

Description

Creates one message definition as its own document. Mendix 11.15 removed the message definition collection and stores each definition as a separate document. Studio Pro’s converter turns a collection into a folder with the collection’s name, holding one document per definition, so MD_Order with the definition OrderMessage becomes the folder MD_Order with the document OrderMessage in it.

The body is the collection’s DEFINITION entry: the same members, the same rules for attributes and associations, the same derived cardinality. See CREATE MESSAGE DEFINITION COLLECTION.

A mapping refers to the document by its two-part name:

mdl 1;
create message definition Sales.OrderMessage
  folder 'Messages/MD_Order'
  for Sales.Order as 'Orders' {
    OrderId,
    Total as 'GrandTotal',
    Sales.OrderLine_Order/Sales.OrderLine as 'Lines' { Sku, Quantity },
    Sales.Order_Customer/Sales.Customer { FirstName, Address example 'Kerstraat 5' }
  };

create import mapping Sales.IMM_Order
  with message definition Sales.OrderMessage
{
  create Sales.Order { OrderId = OrderId }
};

On 11.15 a mapping stores this reference in MessageDefinition2. A script written for an older version can still name a converted definition in three parts (Sales.MD_Order.OrderMessage): it resolves to the document in the folder MD_Order, and the mapping stores the two-part name.

Editing one without restating it

mdl 1;
alter message definition Sales.OrderMessage add member LastName in Customer;
alter message definition Sales.OrderMessage set member Total as 'GrandTotal';
alter message definition Sales.OrderMessage drop member Sku in Lines;

These are the collection’s member edits, addressed by the document’s two-part name.

Other statements

LIST MESSAGE DEFINITIONS [ IN module ];
DESCRIBE MESSAGE DEFINITION module.Name;
DROP MESSAGE DEFINITION [ IF EXISTS ] module.Name;

DESCRIBE writes this statement, so describe and exec round-trip. Dropping a definition that a mapping still uses is refused, and the mappings are named.

On 11.15 the collection statements (CREATE, ALTER, DESCRIBE and DROP MESSAGE DEFINITION COLLECTION) are refused with a hint to use this form, and LIST MESSAGE DEFINITION COLLECTIONS points to LIST MESSAGE DEFINITIONS.

See Also

CREATE MESSAGE DEFINITION COLLECTION, CREATE IMPORT MAPPING, CREATE EXPORT MAPPING

CREATE IMPORT MAPPING

Synopsis

[ /** description */ ]
CREATE [ OR MODIFY ] IMPORT MAPPING module.Name
    [ FOLDER 'folder_path' ]
    [ WITH JSON STRUCTURE module.JsonStructure ]
    [ WITH XML SCHEMA module.XmlSchema ]
{
    { CREATE | FIND | FIND OR CREATE } module.Entity {
        Attribute = jsonField [ KEY ],
        ...
        [ { CREATE | FIND | FIND OR CREATE } Assoc/module.ChildEntity = nestedKey {
            ChildAttr = childField [ KEY ],
            ...
        } ]
    }
};

Description

Creates an import mapping that reads JSON or XML data and creates or finds Mendix entity objects from it.

An import mapping is invoked in a microflow using IMPORT FROM MAPPING module.Mapping($json). The result is the root entity object populated from the parsed data.

Object Handling

Each entity block specifies one of three strategies:

KeywordBehaviour
CREATEAlways create a new object
FINDFind an existing object by the KEY attribute; error if not found
FIND OR CREATEFind an existing object by the KEY attribute; create if not found (upsert)

Marking an attribute with KEY designates it as the lookup key for FIND and FIND OR CREATE.

Requirements for FIND

An element that searches has two requirements, both refused by mxcli check and by exec rather than left to the build:

RuleRequirementBuild error if ignored
MDL-MAP02At least one member marked KEY, on each searching elementCE0250 “Object element must have a key defined…”
MDL-MAP03The entity must be persistableCE0251 “Searching for an object is not allowed for the entity ‘X’, because it is not persistable.”

Persistability is taken from the generalization chain, not the entity’s own setting: an entity declared with plain CREATE ENTITY that extends a non-persistent parent is still not searchable.

An element with a custom handler (FIND module.Entity BY module.Microflow(...)) is exempt from both — the microflow is the find.

MDL-MAP02 needs no project. MDL-MAP03 resolves the entity from the script when the script creates it, and from the project otherwise; an entity it cannot resolve is left alone rather than assumed non-persistable.

Nested Objects and Arrays

Child entities are nested inside the parent’s { } block using the association path:

AssociationName/module.ChildEntity = jsonKey { ... }

The association must be defined such that the child entity owns the foreign key (FROM child TO parent). Arrays in the JSON sample automatically map to multiple child objects.

OR MODIFY

If OR MODIFY is specified and the mapping already exists, it is updated in place. The document UUID is preserved, which protects runtime microflow references that call IMPORT FROM MAPPING.

Parameters

module.Name

The qualified name of the import mapping.

FOLDER 'folder_path'

Optional. Places the document in the named module folder, creating missing folders in the path. On CREATE OR MODIFY this moves an existing document; omitting the clause leaves placement alone rather than returning the document to the module root. See MOVE.

/** description */

Optional. The documentation of the import mapping, as a doc comment before the statement. lint QUAL002 reports a import mapping without it, and DESCRIBE prints it back. On CREATE OR MODIFY, no doc comment keeps the stored documentation and an empty /** */ clears it.

WITH JSON STRUCTURE module.JsonStructure

Associates the mapping with the named JSON structure. The structure defines the JSON shape.

WITH XML SCHEMA module.XmlSchema

Associates the mapping with the named XML schema instead of a JSON structure.

The reference is resolved against the project: a name that matches no XML schema is refused by mxcli check -p and by exec, naming the schemas that exist. mxbuild otherwise reports it as CE1613 “The selected XML schema ‘X’ no longer exists” at the end of a build.

A project holding no XML schemas disables the check rather than refusing every mapping — there is no CREATE XML SCHEMA in MDL, so an XML schema is only ever something imported into the project by hand, and having none is the ordinary case. The same resolution applies to WITH JSON STRUCTURE.

MDL cannot state the XML half of such a mapping — the schema element it is rooted at, or the schema path of each element — and mxcli does not read the XSD to derive them. So CREATE OR MODIFY of an existing XML-schema mapping that carries them is accepted only when the statement is the mapping’s own DESCRIBE output, which reports Unchanged and writes nothing; any other rewrite is refused rather than written without its root element (which mxbuild reports as CE0270 “No root element could be found in the schema”). Edit such a mapping in Studio Pro.

CREATE | FIND | FIND OR CREATE

Object handling strategy for each entity block.

Attribute = jsonField [ KEY ]

Maps the JSON field jsonField to entity attribute Attribute. KEY marks this attribute for FIND lookups.

AssociationName/module.ChildEntity = nestedKey

Maps a nested JSON object or array element to a child entity via the named association.

Examples

Simple flat mapping

CREATE IMPORT MAPPING MyModule.IMM_Pet
    WITH JSON STRUCTURE MyModule.JSON_Pet
{
    CREATE MyModule.PetResponse {
        PetId = id,
        Name  = name,
        Status = status
    }
};

Upsert by key attribute

CREATE IMPORT MAPPING MyModule.IMM_UpsertPet
    WITH JSON STRUCTURE MyModule.JSON_Pet
{
    FIND OR CREATE MyModule.PetResponse {
        PetId = id KEY,
        Name  = name,
        Status = status
    }
};

Nested JSON with child entity

CREATE IMPORT MAPPING MyModule.IMM_Order
    WITH JSON STRUCTURE MyModule.JSON_Order
{
    CREATE MyModule.OrderResponse {
        OrderId = orderId,
        CREATE MyModule.CustomerInfo_OrderResponse/MyModule.CustomerInfo = customer {
            Name  = name,
            Email = email
        },
        CREATE MyModule.OrderItem_OrderResponse/MyModule.OrderItem = items {
            Sku      = sku,
            Quantity = quantity,
            Price    = price
        }
    }
};

Idempotent update

CREATE OR MODIFY IMPORT MAPPING MyModule.IMM_Pet
    WITH JSON STRUCTURE MyModule.JSON_Pet
{
    CREATE MyModule.PetResponse {
        PetId  = id,
        Name   = name,
        Status = status
    }
};

Notes

  • The child entity must own the FK (FROM child TO parent). Using the wrong association direction causes validation errors at runtime.
  • Arrays in the JSON sample map directly to child entity objects — there is no intermediate container entity (unlike export mappings).
  • Import and export of the same JSON typically require different entity structures because of the FK direction difference.

Inherited attributes

Mendix inheritance is multi-table: all of a parent’s attributes are members of the child, so an entity declared with EXTENDS can map them. Name an inherited attribute exactly like one of the entity’s own; mxcli resolves each to the entity that declares it.

mdl 1;
CREATE PERSISTENT ENTITY Docs.DocumentBase (DocName: String(200), Confidential: Boolean);
CREATE PERSISTENT ENTITY Docs.Contract EXTENDS Docs.DocumentBase (ContractNumber: String(50));

CREATE IMPORT MAPPING Docs.IMM_Contract
  WITH JSON STRUCTURE Docs.JSON_Contract
{
  create Docs.Contract {
    ContractNumber = contractNumber,
    DocName        = docName,
    Confidential   = confidential
  }
};

Referencing an inherited attribute against the entity being mapped rather than its declaring entity is Mendix CE1613 “The selected attribute … no longer exists”, and Studio Pro shows the field unmapped.

See Also

CREATE JSON STRUCTURE, CREATE EXPORT MAPPING

CREATE EXPORT MAPPING

Synopsis

[ /** description */ ]
CREATE [ OR MODIFY ] EXPORT MAPPING module.Name
    [ FOLDER 'folder_path' ]
    [ WITH JSON STRUCTURE module.JsonStructure ]
    [ WITH XML SCHEMA module.XmlSchema ]
    [ NULL VALUES { LeaveOutElement | SendAsNil } ]
{
    module.Entity {
        jsonField = Attribute,
        ...
        [ Assoc/module.ChildEntity AS alias {
            childField = ChildAttr,
            ...
        } ]
    }
};

Description

Creates an export mapping that serialises Mendix entity objects to JSON or XML.

An export mapping is invoked in a microflow using EXPORT TO MAPPING module.Mapping($Object). The result is a JSON or XML string.

NULL VALUES

Controls how empty attribute values are serialised:

ValueBehaviour
LeaveOutElementOmit the JSON key entirely (default)
SendAsNilInclude the key with a null JSON value

Nested Objects and Arrays

Child entities are nested using the association path followed by AS alias:

Assoc/module.ChildEntity AS alias { ... }

For arrays, Studio Pro’s domain model convention is to use an intermediate container entity (no attributes) between the root and the item entity. See the example below.

OR MODIFY

If OR MODIFY is specified and the mapping already exists, it is updated in place. The document UUID is preserved, which protects runtime microflow references that call EXPORT TO MAPPING.

Parameters

module.Name

The qualified name of the export mapping.

FOLDER 'folder_path'

Optional. Places the document in the named module folder, creating missing folders in the path. On CREATE OR MODIFY this moves an existing document; omitting the clause leaves placement alone rather than returning the document to the module root. See MOVE.

/** description */

Optional. The documentation of the export mapping, as a doc comment before the statement. lint QUAL002 reports a export mapping without it, and DESCRIBE prints it back. On CREATE OR MODIFY, no doc comment keeps the stored documentation and an empty /** */ clears it.

WITH JSON STRUCTURE module.JsonStructure

Associates the mapping with the named JSON structure.

WITH XML SCHEMA module.XmlSchema

Associates the mapping with the named XML schema.

The reference is resolved against the project: a name that matches no XML schema is refused by mxcli check -p and by exec, naming the schemas that exist. mxbuild otherwise reports it as CE1613 “The selected XML schema ‘X’ no longer exists” at the end of a build.

A project holding no XML schemas disables the check rather than refusing every mapping — there is no CREATE XML SCHEMA in MDL, so an XML schema is only ever something imported into the project by hand, and having none is the ordinary case. The same resolution applies to WITH JSON STRUCTURE.

MDL cannot state the XML half of such a mapping — the schema element it is rooted at, or the schema path of each element — and mxcli does not read the XSD to derive them. So CREATE OR MODIFY of an existing XML-schema mapping that carries them is accepted only when the statement is the mapping’s own DESCRIBE output, which reports Unchanged and writes nothing; any other rewrite is refused rather than written without its root element (which mxbuild reports as CE0270 “No root element could be found in the schema”). Edit such a mapping in Studio Pro.

NULL VALUES LeaveOutElement | SendAsNil

Controls null serialisation. Defaults to LeaveOutElement.

jsonField = Attribute

Maps entity attribute Attribute to the JSON key jsonField.

Assoc/module.ChildEntity AS alias

Maps associated objects to a nested JSON object or array using the named alias.

Examples

Simple flat mapping

CREATE EXPORT MAPPING MyModule.EMM_Pet
    WITH JSON STRUCTURE MyModule.JSON_Pet
{
    MyModule.PetResponse {
        id     = PetId,
        name   = Name,
        status = Status
    }
};

With null values option

CREATE EXPORT MAPPING MyModule.EMM_PetNullable
    WITH JSON STRUCTURE MyModule.JSON_Pet
    NULL VALUES SendAsNil
{
    MyModule.PetResponse {
        id     = PetId,
        name   = Name,
        status = Status
    }
};

Nested object with array (container + item pattern)

-- Domain model for export:
--   ExRoot → ExCustomer (object), ExRoot → ExItems (container) → ExItemsItem (array item)

CREATE EXPORT MAPPING MyModule.EMM_Order
    WITH JSON STRUCTURE MyModule.JSON_Order
{
    MyModule.ExRoot {
        orderId = OrderId,
        MyModule.ExCustomer_ExRoot/MyModule.ExCustomer AS customer {
            name  = Name,
            email = Email
        },
        MyModule.ExItems_ExRoot/MyModule.ExItems AS items {
            MyModule.ExItemsItem_ExItems/MyModule.ExItemsItem AS ItemsItem {
                sku      = Sku,
                quantity = Quantity,
                price    = Price
            }
        }
    }
};

Idempotent update

CREATE OR MODIFY EXPORT MAPPING MyModule.EMM_Pet
    WITH JSON STRUCTURE MyModule.JSON_Pet
{
    MyModule.PetResponse {
        id     = PetId,
        name   = Name,
        status = Status
    }
};

Notes

  • Export mappings use a different entity structure than import mappings for the same JSON. Arrays in export require an intermediate container entity (no attributes) between the root entity and the array item entity.
  • Import and export mappings cannot share the same domain model for nested JSON because the FK direction and entity structure differ.

See Also

CREATE JSON STRUCTURE, CREATE IMPORT MAPPING

CREATE DATA TRANSFORMER

Synopsis

[ /** description */ ]
CREATE [ OR MODIFY ] DATA TRANSFORMER module.Name
    [ FOLDER 'folder_path' ]
    SOURCE { JSON | XML } 'sample'
{
    { JSLT | XSLT } 'transformation';
    [ { JSLT | XSLT } 'next_step'; ... ]
};

Description

Creates a data transformer that reshapes JSON or XML data through one or more transformation steps before it enters an import mapping or is returned to a caller.

A data transformer applies a pipeline of JSLT (for JSON) or XSLT (for XML) transformations to its input. Steps execute in order; each step’s output becomes the next step’s input.

If OR MODIFY is specified and the transformer already exists, it is updated in place. The document UUID is preserved.

JSLT

JSLT is a JSON transformation language designed for structural reshaping. Common operations:

  • Field selection: .field
  • Rename: "newName": .oldName
  • Arithmetic: .price * .quantity
  • Conditional: if (.status == "active") "yes" else "no"

Source Sample

The SOURCE clause provides a representative input document. Mendix uses it to validate the transformation at design time. The actual runtime input may differ in values but must match the structure.

Parameters

module.Name
The qualified name of the data transformer.
FOLDER 'folder_path'
Optional. Places the document in the named module folder, creating missing folders in the path. On CREATE OR MODIFY this moves an existing document; omitting the clause leaves placement alone rather than returning the document to the module root. See MOVE.
/** description */
Optional. The documentation of the data transformer, as a doc comment before the statement. lint QUAL002 reports a data transformer without it, and DESCRIBE prints it back. On CREATE OR MODIFY, no doc comment keeps the stored documentation and an empty /** */ clears it.
SOURCE JSON 'sample'
A representative JSON input document. Multi-line samples can use $$...$$ quoting.
SOURCE XML 'sample'
A representative XML input document.
JSLT 'transformation'
A JSLT transformation expression applied to the current input.
XSLT 'transformation'
An XSLT stylesheet applied to the current input.

Examples

Single-step JSON flattening

CREATE DATA TRANSFORMER MyModule.FlattenNested
    SOURCE JSON '{"wrapper": {"value": 42, "label": "hello"}}'
{
    JSLT '{"value": .wrapper.value, "label": .wrapper.label}';
};

Multi-line JSLT with $$ quoting

CREATE DATA TRANSFORMER MyModule.WeatherTransform
    SOURCE JSON '{"latitude": 51.9, "longitude": 4.5, "current": {"temperature_2m": 12.8, "wind_speed_10m": 18.3}}'
{
    JSLT $$
{
    "lat":        .latitude,
    "lon":        .longitude,
    "temp":       .current.temperature_2m,
    "wind_speed": .current.wind_speed_10m
}
    $$;
};

Idempotent update

CREATE OR MODIFY DATA TRANSFORMER MyModule.WeatherTransform
    SOURCE JSON '{"latitude": 51.9, "longitude": 4.5, "timezone": "UTC", "current": {"temperature_2m": 12.8}}'
{
    JSLT $$
{
    "lat":      .latitude,
    "lon":      .longitude,
    "timezone": .timezone,
    "temp":     .current.temperature_2m
}
    $$;
};

See Also

CREATE JSON STRUCTURE, CREATE IMPORT MAPPING

Agent Editor Statements

Statements for managing AI agent editor documents. Requires Mendix 11.9+ and the AgentEditorCommons module.

The Mendix Agent Editor introduces four document types that must be set up in dependency order:

  1. Model — an LLM configuration referencing a Mendix Cloud GenAI Portal resource key
  2. Knowledge Base — a vector knowledge base for retrieval-augmented generation
  3. Consumed MCP Service — an external tool server (Model Context Protocol)
  4. Agent — an AI agent that references a model and optionally uses knowledge bases and MCP services

A fifth, the Published MCP Service (Agent Editor 2.6+, MCP Server 5.x), is the other direction: an MCP server the app publishes, exposing its microflows as tools.

Statements

StatementDescription
CREATE AI MODELDefine an LLM model configuration
CREATE KNOWLEDGE BASEDefine a vector knowledge base
CREATE CONSUMED MCP SERVICERegister an external MCP tool server
CREATE PUBLISHED MCP SERVICEPublish the app’s microflows as an MCP server
CREATE AGENTDefine an AI agent with prompts and optional tools
StatementSyntax
List AI modelsLIST AI MODELS [IN module]
List knowledge basesLIST KNOWLEDGE BASES [IN module]
List consumed MCP servicesLIST CONSUMED MCP SERVICES [IN module]
List published MCP servicesLIST PUBLISHED MCP SERVICES [IN module]
List agentsLIST AGENTS [IN module]
DescribeDESCRIBE { MODEL | KNOWLEDGE BASE | CONSUMED MCP SERVICE | PUBLISHED MCP SERVICE | AGENT } module.Name

Drop Statements

mdl 1;
DROP AI MODEL module.Name;
DROP KNOWLEDGE BASE module.Name;
DROP CONSUMED MCP SERVICE module.Name;
DROP PUBLISHED MCP SERVICE module.Name;
DROP AGENT module.Name;

Prerequisites

  • Mendix 11.9+
  • AgentEditorCommons module installed
  • Encryption module configured (32-character key)
  • ASU_AgentEditor registered as after-startup microflow

Version Gate

All agent editor statements are version-gated to Mendix 11.9+. Running them against an older project produces:

feature not available: agent editor requires Mendix >= 11.9

CREATE AI MODEL

Synopsis

CREATE [ OR MODIFY ] AI MODEL module.Name (
    Provider: MxCloudGenAI,
    key: @module.KeyConstant
    [, DisplayName: 'display name' ]
    [, KeyName: 'portal key name' ]
    [, Environment: 'environment' ]
);

DROP AI MODEL module.Name;

Requires Mendix 11.9+.

Description

Creates an agent-editor Model document, which Studio Pro calls an AI model. MODEL without AI is the old spelling: it still parses and warns MDL-DEPR131. A model represents an LLM configuration in the Mendix Agent Editor. It references a String constant that holds the Mendix Cloud GenAI Portal resource key.

At runtime, the ASU_AgentEditor after-startup microflow reads the constant value and registers the corresponding GenAICommons.DeployedModel, making the model available to agents.

DisplayName, KeyName, and Environment are normally populated by Studio Pro when the user clicks “Test Key” against the Portal. They can be specified in MDL for round-trip preservation (when DESCRIBE output is re-executed), but setting them manually has no functional effect at runtime.

If OR MODIFY is specified and the model already exists, its properties are updated in place. The document UUID is preserved.

Parameters

module.Name
The qualified name of the model document.
Provider: MxCloudGenAI
The provider: MxCloudGenAI, Azure (Azure AI Foundry, through the OpenAI connector) or Fuse (Agent Editor 2.7+), the values Agent Editor’s import accepts. mxcli check warns on any other value (MDL-AGENT01), and on a value in the wrong case, because the build does not read it and the import fails only at startup. It does not refuse it, because Agent Editor adds providers.
key: module.KeyConstant
A qualified reference to a String constant that holds the Portal resource key. The constant must exist before the model is created.
DisplayName: 'display name'
Optional. The display name shown in Studio Pro. Populated by Studio Pro after key validation.
KeyName: 'portal key name'
Optional. The Portal key name. Populated by Studio Pro after key validation.
Environment: 'environment'
Optional. The deployment environment. Populated by Studio Pro after key validation.

Examples

Minimal model

mdl 1;
CREATE CONSTANT MyModule."ModelKey"
    ( Type: String, DefaultValue: '' );

CREATE AI MODEL MyModule."GPT4Model" (
    Provider: MxCloudGenAI,
    key: @MyModule.ModelKey
);

Model with Portal metadata (for round-trip scripts)

CREATE AI MODEL MyModule."ConfiguredModel" (
    Provider: MxCloudGenAI,
    key: @MyModule.ModelKey,
    DisplayName: 'GPT-4 Turbo (128K)',
    KeyName: 'prod-gpt4-turbo',
    Environment: 'production'
);

Idempotent upsert

CREATE OR MODIFY AI MODEL MyModule."GPT4Model" (
    Provider: MxCloudGenAI,
    key: @MyModule.ModelKey,
    DisplayName: 'GPT-4 Turbo (Updated)'
);

Cleanup

DROP AI MODEL MyModule.GPT4Model;

See Also

CREATE AGENT, CREATE KNOWLEDGE BASE

CREATE KNOWLEDGE BASE

Synopsis

CREATE [ OR MODIFY ] KNOWLEDGE BASE module.Name (
    Provider: MxCloudGenAI,
    key: @module.KeyConstant
);

DROP KNOWLEDGE BASE module.Name;

Requires Mendix 11.9+.

Description

Creates an agent-editor Knowledge Base document. A knowledge base connects an agent to a vector knowledge base for retrieval-augmented generation (RAG). It references a String constant that holds the Mendix Cloud GenAI Portal resource key for the knowledge base.

Knowledge bases are used inside CREATE AGENT body blocks to attach retrieval capabilities to an agent.

If OR MODIFY is specified and the knowledge base already exists, its properties are updated in place. The document UUID is preserved.

Parameters

module.Name
The qualified name of the knowledge base document.
Provider: MxCloudGenAI
The provider. MxCloudGenAI is the only supported value; mxcli check warns on any other (MDL-AGENT01).
key: module.KeyConstant
A qualified reference to a String constant that holds the Portal resource key for the knowledge base.

Examples

Create a knowledge base

mdl 1;
CREATE CONSTANT MyModule."KBKey"
    ( Type: String, DefaultValue: '' );

CREATE KNOWLEDGE BASE MyModule."ProductDocs" (
    Provider: MxCloudGenAI,
    key: @MyModule.KBKey
);

Idempotent upsert

CREATE OR MODIFY KNOWLEDGE BASE MyModule."ProductDocs" (
    Provider: MxCloudGenAI,
    key: @MyModule.KBKey
);

See Also

CREATE AGENT, CREATE AI MODEL

CREATE CONSUMED MCP SERVICE

Synopsis

CREATE [ OR MODIFY ] CONSUMED MCP SERVICE module.Name (
    ProtocolVersion: v2025_03_26,
    Endpoint: @module.UrlConstant,
    [ AuthenticationMicroflow: module.GetHeaders, ]
    version: '1.0'
    [, ConnectionTimeoutSeconds: 30 ]
    [, documentation: 'description' ]
);

DROP CONSUMED MCP SERVICE module.Name;

Requires Mendix 11.9+.

Description

Creates a Consumed MCP Service document. A consumed MCP service represents a remote tool server that implements the Model Context Protocol (MCP). Once registered, agents can use the tools provided by the MCP server.

Consumed MCP services are referenced inside CREATE AGENT body blocks using the MCP SERVICE keyword.

If OR MODIFY is specified and the service already exists, its properties are updated in place. The document UUID is preserved.

Parameters

module.Name
The qualified name of the consumed MCP service document.
ProtocolVersion: v2025_03_26
The MCP protocol version. Use the token form (e.g. v2025_03_26), not a quoted string.
Endpoint: @module.UrlConstant
The String constant holding the MCP server’s URL (e.g. https://example.com/mcp). Required in practice: at startup Agent Editor reads the constant’s value and skips a service whose constant has none (“No value could be found for the local configuration for the constant”), so the agents that use it get no tools. mxcli check warns on a CREATE without it (MDL-AGENT03), check --references and exec on an OR MODIFY that leaves the service without one, and mxcli lint on a stored service without one (MPR015); check --references and exec refuse a constant that does not exist or is not a String. Set the value per environment. On OR MODIFY, left out, the stored endpoint stays.
AuthenticationMicroflow: module.GetHeaders
Optional. The microflow that supplies credentials. MCP Client calls it with no arguments and sends the List of System.HttpHeader it returns with each request (build the list with MCPClient.HttpHeader_CreateAndAddToList). check --references refuses one with parameters or another return type. On OR MODIFY, left out, the stored microflow stays; ALTER … SET AuthenticationMicroflow = '' removes it.
version: '1.0'
The service version as a quoted string.
ConnectionTimeoutSeconds: 30
Optional. Connection timeout in seconds. Defaults to 30.
documentation: 'description'
Optional. A human-readable description of the MCP service.

Examples

Register an MCP service

mdl 1;
CREATE CONSTANT MyModule.WebSearchUrl (Type: String, DefaultValue: 'https://example.com/mcp');

CREATE CONSUMED MCP SERVICE MyModule."WebSearch" (
    ProtocolVersion: v2025_03_26,
    Endpoint: @MyModule.WebSearchUrl,
    version: '1.0',
    ConnectionTimeoutSeconds: 30,
    documentation: 'Web search MCP server for research tasks'
);

Idempotent upsert

CREATE OR MODIFY CONSUMED MCP SERVICE MyModule."WebSearch" (
    ProtocolVersion: v2025_03_26,
    version: '1.1',
    ConnectionTimeoutSeconds: 60,
    documentation: 'Updated web search MCP service'
);

See Also

CREATE AGENT, CREATE AI MODEL

CREATE PUBLISHED MCP SERVICE

Synopsis

CREATE [ OR MODIFY ] PUBLISHED MCP SERVICE module.Name [ FOLDER 'path' ] (
    [ ServerName: 'name', ]
    EndpointPath: 'path',
    Version: '1.0.0',
    ProtocolVersion: v2025_03_26
    [, AuthenticationEnabled: true ]
    [, AuthenticationMicroflow: module.Microflow ]
    [, AuthorizationServerUrl: 'https://...' ]
    [, Scopes: 'scope another-scope' ]
    [, Documentation: 'description' ]
) [ {
    tool name ( Microflow: module.Microflow [, Description: '...'] [, Title: '...'] [, Schema: '...'] )
    ...
} ];

ALTER PUBLISHED MCP SERVICE module.Name
    { SET key = value [, ...] | ADD TOOL name ( ... ) | DROP TOOL name } ...;

DESCRIBE PUBLISHED MCP SERVICE module.Name;
LIST PUBLISHED MCP SERVICES [ IN module ];
DROP PUBLISHED MCP SERVICE module.Name;

Requires Agent Editor 2.6+ and the MCP Server module 5.x (measured on Studio Pro 11.16 with Agent Editor 2.6.0 and MCP Server 5.4.0).

Description

Creates a Published MCP Service document: an MCP server the app itself publishes, so external agents (Claude Desktop, other apps) can call its microflows as tools. This is the model configuration of an MCP server. At startup AgentEditorCommons reads every published MCP service document and starts it through MCPServer.CreateMCPServer and MCPServer.AddTool, so each property is one of those actions’ parameters.

The runtime configuration is the alternative: a startup microflow that calls the same Java actions, which is the only option before MCP Server 5. It is plain microflow logic, written with call java action (see the mcp-server skill). The two map one to one, which is what a migration between them follows. Prompts exist only in runtime configuration (MCPServer.AddPrompt); the document has no place for them.

If OR MODIFY is specified and the service exists, it is rewritten in place: a tool keeps its stored uuid when its name matches, and keys Agent Editor stores that MDL does not model are kept.

Parameters

module.Name
The document name.
ServerName: 'name'
Optional. The server name MCP clients see. Studio Pro stores it but its editor does not show it: a new document gets the document name, and a duplicated one keeps the original’s (Published_MCP_service_2 serves as Published_MCP_service). Omitted, create writes the document name and create or modify keeps the stored value; describe prints it only when it differs from the document name.
EndpointPath: 'path'
The middle segment of the server’s URL, which is <app URL>/<EndpointPath>/mcp: the module appends /mcp, as the editor’s Endpoint field shows (https://{{applicationURL}}/ + path + /mcp). It cannot be mcp, end in /mcp, or contain / (the runtime matches only the first path segment): check reports each as MDL-MCPSERVER01.
Version: '1.0.0'
The server version reported to clients.
ProtocolVersion: v2025_03_26
The MCP protocol version, a value of the module’s MCPServer.ENUM_ProtocolVersion (MCP Server 5.4 has only v2025_03_26).
AuthenticationEnabled: true
The editor’s Requires authentication. Turns authentication on. While it is false, the runtime ignores the three properties below.
AuthenticationMicroflow: module.Microflow
Authenticates every request. It takes System.HttpRequest and/or MCPServer.McpServer, returns a System.User (or a specialization), and returns empty to refuse the request with 401. Without it, an enabled service accepts every request.
AuthorizationServerUrl: 'https://...'
The issuer URL of the OAuth authorization server. The server publishes it as Protected Resource Metadata so clients can discover where to sign in. Needs an authentication microflow, which must validate the access token; must be an absolute http(s) URL.
Scopes: 'a b'
Space-separated OAuth scopes clients must request. Needs an authorization server URL.
Documentation: 'description'
A description of the service.
tool name ( ... )
A microflow exposed as an MCP tool, named as clients see it. Microflow is required; Description is what the calling model reads; Title is a display name; Schema overrides the JSON input schema the module otherwise derives from the microflow’s parameters. A tool microflow takes primitives (named as the schema’s properties) and optionally MCPServer.Tool, and returns a String or an MCPServer.TextContent. The editor’s tool grid shows Name, Description and Source (the microflow); Title and Schema are not in it, but the runtime reads both.

Examples

Publish two microflows

Served at <app URL>/orders/mcp.

CREATE PUBLISHED MCP SERVICE Shop.OrdersMCP (
    EndpointPath: 'orders',
    Version: '1.0.0',
    ProtocolVersion: v2025_03_26,
    Documentation: 'Order tools for agents'
) {
    tool get_status ( Microflow: Shop.MCPTool_GetStatus, Description: 'Status of one order', Title: 'Get status' )
    tool list_orders ( Microflow: Shop.MCPTool_ListOrders, Description: 'List open orders' )
};

OAuth with Microsoft Entra ID

CREATE OR MODIFY PUBLISHED MCP SERVICE Shop.OrdersMCP (
    EndpointPath: 'orders',
    Version: '1.0.0',
    ProtocolVersion: v2025_03_26,
    AuthenticationEnabled: true,
    AuthenticationMicroflow: Shop.MCP_ValidateToken,
    AuthorizationServerUrl: 'https://login.microsoftonline.com/my-tenant-id/v2.0',
    Scopes: 'api://orders/mcp.read'
) {
    tool get_status ( Microflow: Shop.MCPTool_GetStatus, Description: 'Status of one order' )
};

Change one property, add and drop a tool

ALTER PUBLISHED MCP SERVICE Shop.OrdersMCP
    SET Version = '1.1.0'
    ADD TOOL cancel_order ( Microflow: Shop.MCPTool_Cancel, Description: 'Cancel an order' )
    DROP TOOL list_orders;

See Also

CREATE CONSUMED MCP SERVICE, CREATE AGENT

CREATE AGENT

Synopsis

CREATE [ OR MODIFY ] AGENT module.Name (
    UsageType: { Task | Conversational },
    model: module.ModelName,
    SelectedModel: 'model id',
    SystemPrompt: 'prompt' | $$ multi-line prompt $$,
    UserPrompt: 'prompt' | $$ multi-line prompt $$
    [, description: 'description' ]
    [, variables: ( "Key": EntityAttribute [, ...] ) ]
    [, MaxTokens: 16384 ]
    [, Temperature: 0.7 ]
    [, ToolChoice: { Auto | None | Required } ]
)
[ {
    [ MCP SERVICE module.McpServiceName (
        Enabled: true
    ) ]
    [ KNOWLEDGE BASE alias (
        source: module.KBName,
        collection: 'collection-name',
        MaxResults: 5,
        description: 'description',
        Enabled: true
    ) ]
} ]
;

DROP AGENT module.Name;

Requires Mendix 11.9+.

Description

Creates an agent document in the Mendix Agent Editor. An agent defines how an AI assistant behaves: which model it uses, what instructions it follows, and which tools and knowledge bases it can access.

Usage Types

ValueDescription
TaskSingle-turn task completion
ConversationalMulti-turn conversational agent

Prompts

Both SystemPrompt and UserPrompt accept single-quoted strings or dollar-quoted ($$...$$) multi-line strings. Dollar-quoted strings preserve line breaks and are useful for structured prompts.

Variables

The variables clause declares named template variables that can be referenced in prompts using {{VariableName}} syntax. Each variable maps a name to a Mendix type (e.g., EntityAttribute).

variables: ("Language": EntityAttribute)
SystemPrompt: 'Translate the text into {{Language}}.'

Body Blocks

The optional body block (inside { }) attaches tools and knowledge bases to the agent:

  • MCP SERVICE — attaches a consumed MCP service by its qualified name
  • KNOWLEDGE BASE — attaches a knowledge base with retrieval settings
  • TOOL — attaches a microflow tool; Document: Module.Microflow is the microflow it runs

Each attachment is a child of the agent, so its properties are in ( ). The older brace form (TOOL Name { … }) still parses but warns (MDL-DEPR071); mxcli fmt --upgrade rewrites it.

OR MODIFY

If OR MODIFY is specified and the agent already exists, its properties and body blocks are updated in place. The document UUID is preserved, and so is each tool’s and knowledge base’s identity: a tool keeps its stored entry when its type and name match (an MCP service when the service matches). Settings that MDL cannot spell keep their stored values, both on OR MODIFY and on ALTER AGENT: a tool’s user approval and display title and description, the agent’s streaming switches and selected model, and a knowledge base tool’s minimum similarity.

Parameters

module.Name
The qualified name of the agent document.
UsageType
Whether the agent is Task (single-turn) or Conversational (multi-turn).
model: module.ModelName
Qualified reference to the model document the agent uses.
SelectedModel
The id of the model the agent uses, one the Model document’s resource offers (e.g. 'eu.anthropic.claude-sonnet-5'). Required in practice from Agent Editor 2.2: its import refuses an agent without one (“Model with name is referenced but could not be found for the Agent”), and that refusal stops the import of every agent document at startup. mxcli check warns on a create agent without it (MDL-AGENT02), check --references and exec on a create or modify that leaves the agent without one, and mxcli lint on a stored agent without one (MPR014). Only the id is read at runtime; it is matched against GenAICommons.DeployedModel.Model. To list the ids, with the app running: mxcli oql "select Model from MxGenAIConnector.MxCloudDeployedModel" (reported to work in ako/mxcli#2031). '' clears it.
SelectedModelName
Optional. The display name Studio Pro stores next to the id (e.g. 'Anthropic Claude Sonnet 5'); nothing at runtime reads it. Defaults to the id, or keeps the stored name while the id is unchanged.
SystemPrompt
Instructions for the AI model’s behavior and persona.
UserPrompt
Default user message or prompt template.
description
Optional human-readable description of the agent’s purpose.
variables
Optional template variable declarations. Keys are quoted strings; values are Mendix type keywords.
MaxTokens
Optional maximum number of tokens in the response.
Temperature
Optional sampling temperature (0.0–1.0). Higher values produce more creative output.
ToolChoice
Optional. Auto (let the model decide), None (no tools), or Required (always use a tool).
EnableBackendStreaming, EnableUIStreaming
Optional, true or false. Response streaming for the agent (Agent Editor 2.2.1+). Streaming is set on the agent, not on its model. The Studio Pro playground does not show it; it works at runtime with a chat agent in Conversational UI.

Tool and MCP service settings

UserAccessApproval
Optional. HiddenForUser: the call runs without being shown. VisibleForUser: the call is shown and runs. UserConfirmationRequired: the call is shown and waits for the user’s approval. Agent Editor 2.2+.
DisplayTitle, DisplayDescription
Optional. The title and description shown to the user for the call. The model still sees the tool’s name and Description.

Knowledge base settings

MinSimilarity
Optional. The least similarity, between 0 and 1, a chunk needs to be returned.
IsVisible
Optional, true or false. Whether the user sees the retrieval.

On OR MODIFY, a setting the statement leaves out keeps its stored value.

Examples

Simple task agent

CREATE AGENT MyModule."Summarizer" (
    UsageType: Task,
    model: MyModule.GPT4Model,
    SelectedModel: 'eu.anthropic.claude-sonnet-5',
    SystemPrompt: 'Summarize the given text in 3 sentences.',
    UserPrompt: 'Enter text to summarize.'
);

Agent with variable substitution

CREATE AGENT MyModule."Translator" (
    UsageType: Task,
    model: MyModule.GPT4Model,
    SelectedModel: 'eu.anthropic.claude-sonnet-5',
    variables: ("Language": EntityAttribute),
    SystemPrompt: 'Translate the text into {{Language}}.',
    UserPrompt: 'Hello world'
);

Agent whose tool needs the user’s approval

CREATE AGENT MyModule."Support" (
    UsageType: Chat,
    model: MyModule.GPT4Model,
    SelectedModel: 'eu.anthropic.claude-sonnet-5',
    EnableBackendStreaming: true,
    SystemPrompt: 'Help the customer with their orders.'
)
{
    TOOL CancelOrder (
        Document: MyModule.Tool_CancelOrder,
        Description: 'Cancel an order by its number',
        UserAccessApproval: UserConfirmationRequired,
        DisplayTitle: 'Cancel order'
    )
    KNOWLEDGE BASE Faq (Source: MyModule.FaqKB, MaxResults: 5, MinSimilarity: 0.3)
};

Agent with multi-line dollar-quoted prompt

CREATE AGENT MyModule."CodeReviewer" (
    UsageType: Task,
    model: MyModule.GPT4Model,
    SelectedModel: 'eu.anthropic.claude-sonnet-5',
    SystemPrompt: $$You are a code review assistant.

Review the provided code for:
1. Security vulnerabilities
2. Performance issues
3. Code style and readability

Provide specific, actionable feedback.$$,
    UserPrompt: $$func main() {
    fmt.Println("Hello, World!")
}$$
);

Conversational agent with MCP service and knowledge base

CREATE AGENT MyModule."ResearchAssistant" (
    UsageType: Conversational,
    description: 'Research assistant with web search and documentation',
    model: MyModule.GPT4Model,
    SelectedModel: 'eu.anthropic.claude-sonnet-5',
    MaxTokens: 16384,
    ToolChoice: Auto,
    SystemPrompt: 'You are a research assistant. Use available tools.',
    UserPrompt: 'Find information about quantum computing.'
)
{
    MCP SERVICE MyModule.WebSearch (
        Enabled: true
    )

    KNOWLEDGE BASE ProductKB (
        source: MyModule.ProductDocs,
        collection: 'product-docs',
        MaxResults: 5,
        description: 'Product documentation',
        Enabled: true
    )
};

Idempotent upsert

CREATE OR MODIFY AGENT MyModule."ResearchAssistant" (
    UsageType: Task,
    model: MyModule.GPT4Model,
    MaxTokens: 8192,
    Temperature: 0.5,
    SystemPrompt: $$You are an updated research assistant.$$,
    UserPrompt: 'How can I help you today?'
);

Notes

  • Agents must be dropped before their referenced models, knowledge bases, and MCP services.
  • The model and knowledge base documents must exist before the agent is created.
  • Dollar-quoted prompts ($$...$$) preserve all whitespace including newlines.

See Also

CREATE AI MODEL, CREATE KNOWLEDGE BASE, CREATE CONSUMED MCP SERVICE

Business Event Statements

Statements for managing business event services.

Business event services enable asynchronous, event-driven communication between Mendix applications (and external systems) using the Mendix Business Events platform. A service defines named messages with typed attributes and specifies whether the application publishes or subscribes to each message.

Statements

StatementDescription
CREATE BUSINESS EVENT SERVICEDefine a business event service with messages
DROP BUSINESS EVENT SERVICERemove a business event service
StatementSyntax
Show business eventsLIST BUSINESS EVENTS [IN module]
Describe serviceDESCRIBE BUSINESS EVENT SERVICE module.Name

CREATE BUSINESS EVENT SERVICE

Synopsis

CREATE [ OR MODIFY ] BUSINESS EVENT SERVICE module.Name
(
    ServiceName: 'service_name',
    EventNamePrefix: 'prefix'
)
{
    MESSAGE MessageName ( attr: Type [, ...] ) PUBLISH | SUBSCRIBE
        [ ENTITY module.Entity ] ;
    [ ... ]
}

Description

Creates a business event service that defines one or more messages for event-driven communication between applications.

Each message has a name, a set of typed attributes, and an operation mode (PUBLISH or SUBSCRIBE). A publishing message means this application sends events of that type. A subscribing message means this application receives and handles events of that type.

The OR MODIFY option updates the existing service in-place, preserving its UUID so that references from other documents remain valid. OR REPLACE is also accepted as a synonym.

Service Properties

The service declaration includes two required properties:

  • ServiceName – The logical service name used for event routing on the Mendix Business Events broker.
  • EventNamePrefix – A prefix prepended to message names to form the fully qualified event name. Can be empty ('').

Message Attributes

Each message defines zero or more attributes with the following supported types:

TypeDescription
StringText value
Integer32-bit integer
Long64-bit integer
BooleanTrue/false
DateTimeDate and time
DecimalArbitrary-precision decimal

Entity Mapping

The optional ENTITY clause on a message links the message to a Mendix entity. For SUBSCRIBE messages, incoming events are mapped to instances of this entity. For PUBLISH messages, entity instances are serialized into outgoing events.

Parameters

module.Name
Qualified name of the business event service (Module.ServiceName).
ServiceName: 'service_name'
The logical service identifier used by the Business Events broker.
EventNamePrefix: 'prefix'
A prefix for event names. Use an empty string ('') for no prefix.
MESSAGE MessageName
The name of a message within the service.
attr: Type
An attribute definition within a message. Multiple attributes are comma-separated.
PUBLISH
This application publishes (sends) this message type.
SUBSCRIBE
This application subscribes to (receives) this message type.
ENTITY module.Entity
Optional entity linked to the message for data mapping.

Examples

Create a service that publishes order events:

CREATE BUSINESS EVENT SERVICE Shop.OrderEvents
(
    ServiceName: 'com.example.shop.orders',
    EventNamePrefix: 'shop'
)
{
    MESSAGE OrderCreated (OrderId: Long, CustomerName: String, Total: Decimal) PUBLISH
        ENTITY Shop.Order;
    MESSAGE OrderShipped (OrderId: Long, TrackingNumber: String) PUBLISH
        ENTITY Shop.Shipment;
};

Create a service that subscribes to external events:

CREATE BUSINESS EVENT SERVICE Inventory.StockUpdates
(
    ServiceName: 'com.example.warehouse.stock',
    EventNamePrefix: ''
)
{
    MESSAGE StockChanged (ProductId: Long, NewQuantity: Integer) SUBSCRIBE
        ENTITY Inventory.StockLevel;
};

Replace an existing service:

CREATE OR MODIFY BUSINESS EVENT SERVICE Shop.OrderEvents
(
    ServiceName: 'com.example.shop.orders',
    EventNamePrefix: 'shop'
)
{
    MESSAGE OrderCreated (OrderId: Long, CustomerName: String, Total: Decimal) PUBLISH;
    MESSAGE OrderCancelled (OrderId: Long, Reason: String) PUBLISH;
};

See Also

DROP BUSINESS EVENT SERVICE

DROP BUSINESS EVENT SERVICE

Synopsis

DROP BUSINESS EVENT SERVICE module.Name;

Description

Removes a business event service from the project. The service is identified by its qualified name. If the service does not exist, an error is returned.

Dropping a service removes all its message definitions and operation implementations. Any microflows that reference the dropped service’s messages will need to be updated.

Parameters

module.Name
The qualified name of the business event service to drop (Module.ServiceName).

Examples

DROP BUSINESS EVENT SERVICE Shop.OrderEvents;

See Also

CREATE BUSINESS EVENT SERVICE

Image Collection Statements

Statements for creating, inspecting, and dropping image collections within modules.

StatementDescription
CREATE IMAGE COLLECTIONCreate a new image collection with optional images
DROP IMAGE COLLECTIONRemove an image collection
SHOW / DESCRIBE IMAGE COLLECTIONList or inspect image collections

CREATE IMAGE COLLECTION

Synopsis

[/** description */]
CREATE [OR MODIFY] IMAGE COLLECTION module.name
    [EXPORT LEVEL 'Hidden' | 'Public']
    [{
        IMAGE image_name ( File: 'path' )
        IMAGE image_name ( Data: 'base64' [, Format: png | jpg | gif | svg | bmp | webp] )
        ...
    }];

Description

Creates a new image collection in the specified module. OR MODIFY updates an existing collection in-place, preserving its UUID so that widget references remain valid. OR REPLACE is accepted as a synonym for OR MODIFY. Image collections bundle images (icons, logos, graphics) within a module. Images can be loaded from the filesystem during creation, with the format detected automatically from the file extension.

Parameters

module.name
The qualified name of the collection (e.g., MyModule.AppIcons).
EXPORT LEVEL
Controls visibility from other modules. 'Hidden' (default) restricts access to the owning module. 'Public' makes images available to other modules.
/** … */
Documentation text for the collection, as a doc comment before the statement. The COMMENT '…' clause is its deprecated alias (MDL-DEPR100).
IMAGE name ( File: ‘path’ )
Loads an image from a file on disk. A relative path is resolved against the script’s directory, then the current working directory. Supported formats: PNG, SVG, GIF, JPEG, BMP, WebP, taken from the file extension. A name that is not a plain identifier is written as a quoted identifier ("logo-dark").
IMAGE name ( Data: ‘base64’, Format: fmt )
The image itself, base64-encoded. This is what DESCRIBE IMAGE COLLECTION writes, so its output replays anywhere without the files it came from. Format is needed only when the bytes do not show it (PNG, JPEG, GIF, BMP and WebP signatures, and SVG markup, are recognised).

The images are the collection’s children, so they are in { }, each with its properties in ( ). The older form ( IMAGE name FROM FILE 'path', … ) still parses but warns (MDL-DEPR072); mxcli fmt --upgrade rewrites it.

Examples

Empty collection

CREATE IMAGE COLLECTION MyModule.AppIcons;

Public collection with description

/** Shared icons for all modules */
CREATE IMAGE COLLECTION MyModule.SharedIcons
    EXPORT LEVEL 'Public';

Collection with images

CREATE IMAGE COLLECTION MyModule.NavigationIcons {
    IMAGE home ( File: 'assets/home.png' )
    IMAGE settings ( File: 'assets/settings.svg' )
    IMAGE profile ( File: 'assets/profile.png' )
};

All options combined

/** Company branding assets */
CREATE IMAGE COLLECTION MyModule.BrandAssets
    EXPORT LEVEL 'Public' {
    IMAGE "logo-dark" ( File: 'assets/logo-dark.png' )
    IMAGE "logo-light" ( File: 'assets/logo-light.png' )
};

See Also

DROP IMAGE COLLECTION, SHOW / DESCRIBE IMAGE COLLECTION

DROP IMAGE COLLECTION

Synopsis

DROP IMAGE COLLECTION module.name;

Description

Removes an image collection and all its embedded images from the module.

Parameters

module.name
The qualified name of the collection to drop (e.g., MyModule.AppIcons).

Examples

DROP IMAGE COLLECTION MyModule.StatusIcons;

Check references before dropping

mdl 1;
LIST REFERENCES TO MyModule.AppIcons;
DROP IMAGE COLLECTION MyModule.AppIcons;

See Also

CREATE IMAGE COLLECTION, SHOW / DESCRIBE IMAGE COLLECTION

LIST / DESCRIBE IMAGE COLLECTION

Synopsis

LIST IMAGE COLLECTIONS;
LIST IMAGE COLLECTIONS IN module;
DESCRIBE IMAGE COLLECTION module.name;

Description

LIST IMAGE COLLECTIONS lists all image collections in the project, optionally filtered by module. DESCRIBE IMAGE COLLECTION shows the full definition of a specific collection, including its images as a re-executable CREATE statement.

In the TUI, images are rendered inline when the terminal supports it (Kitty, iTerm2, Sixel).

Parameters

IN module
Filters the listing to a single module.
module.name
The qualified name of the collection to describe (e.g., MyModule.AppIcons).

Examples

List all image collections

LIST IMAGE COLLECTIONS;

Filter by module

LIST IMAGE COLLECTIONS IN MyModule;

View full definition

DESCRIBE IMAGE COLLECTION MyModule.AppIcons;

The output is the complete CREATE OR MODIFY statement, each image written into it as IMAGE name ( Data: '<base64>' ), so it can be copied and re-executed on any machine. DESCRIBE writes no files.

See Also

CREATE IMAGE COLLECTION, DROP IMAGE COLLECTION

Catalog Statements

The catalog is a SQLite database that caches project metadata for fast querying and cross-reference navigation. It is stored in .mxcli/catalog.db next to the MPR file.

A basic REFRESH CATALOG populates tables for modules, entities, attributes, associations, microflows, nanoflows, pages, snippets, enumerations, and workflows. A REFRESH CATALOG FULL additionally populates cross-references (REFS), widgets, strings, source text, and permissions – enabling callers/callees analysis, impact analysis, and full-text search.

StatementDescription
REFRESH CATALOGRebuild the catalog from the current project state
SELECT FROM CATALOGQuery catalog tables with SQL syntax
LIST CATALOG TABLESList available catalog tables and their columns
LIST CALLERS / CALLEESFind what calls an element or what it calls
LIST REFERENCES / IMPACT / CONTEXTCross-reference navigation and impact analysis

REFRESH CATALOG

Synopsis

REFRESH CATALOG [ FULL ] [ FORCE ]

Description

Rebuilds the project metadata catalog from the current state of the open project. The catalog is a SQLite database (.mxcli/catalog.db) that enables fast SQL querying and cross-reference navigation over project elements.

Without any options, REFRESH CATALOG performs a basic rebuild that populates the core metadata tables: MODULES, ENTITIES, ATTRIBUTES, ASSOCIATIONS, MICROFLOWS, NANOFLOWS, PAGES, SNIPPETS, ENUMERATIONS, and WORKFLOWS.

With the FULL option, the rebuild additionally populates cross-reference tables (REFS), widget inventories (WIDGETS), string tables (STRINGS), source text (SOURCE), activity tables (ACTIVITIES), and permission tables (PERMISSIONS). The FULL rebuild is required before using LIST CALLERS, LIST CALLEES, LIST REFERENCES, LIST IMPACT, DESCRIBE CONTEXT, or SEARCH.

The catalog is cached between sessions. If the project has not changed, repeated REFRESH CATALOG calls reuse the cached data. Use FORCE to bypass the cache and rebuild unconditionally – useful after external changes to the MPR file.

Parameters

FULL
Include cross-references, widgets, strings, source text, and permissions in the catalog. Required for callers/callees analysis and full-text search.
FORCE
Bypass the catalog cache and force a complete rebuild regardless of whether the project appears unchanged.

Examples

Basic catalog refresh

REFRESH CATALOG;

Full rebuild with cross-references

REFRESH CATALOG FULL;

Force a complete rebuild

REFRESH CATALOG FULL FORCE;

Refresh from the command line

-- Shell commands:
-- mxcli -p app.mpr -c "REFRESH CATALOG"
-- mxcli -p app.mpr -c "REFRESH CATALOG FULL"
-- mxcli -p app.mpr -c "REFRESH CATALOG FULL FORCE"

See Also

SELECT FROM CATALOG, LIST CATALOG TABLES, LIST CALLERS / CALLEES

SELECT FROM CATALOG

Synopsis

SELECT columns FROM CATALOG.table [ WHERE condition ] [ ORDER BY column [ ASC | DESC ] ] [ LIMIT n ]

Description

Executes a SQL query against the project metadata catalog. The catalog tables are populated by REFRESH CATALOG and can be queried using standard SQL syntax including joins, aggregations, subqueries, and filtering.

All catalog table names are prefixed with CATALOG. (e.g., CATALOG.ENTITIES, CATALOG.MICROFLOWS). The query engine is SQLite, so standard SQLite SQL syntax applies.

The following tables are available after a basic REFRESH CATALOG:

TableDescription
CATALOG.MODULESProject modules
CATALOG.ENTITIESEntities across all modules
CATALOG.ATTRIBUTESEntity attributes
CATALOG.ASSOCIATIONSAssociations between entities
CATALOG.MICROFLOWSMicroflows
CATALOG.NANOFLOWSNanoflows
CATALOG.PAGESPages
CATALOG.SNIPPETSSnippets
CATALOG.ENUMERATIONSEnumerations
CATALOG.WORKFLOWSWorkflows

The following tables require REFRESH CATALOG FULL:

TableDescription
CATALOG.ACTIVITIESIndividual microflow/nanoflow activities
CATALOG.WIDGETSWidget instances across pages and snippets
CATALOG.REFSCross-references between elements
CATALOG.PERMISSIONSAccess rules and role permissions
CATALOG.STRINGSAll string values in the project
CATALOG.SOURCESource text of microflows, pages, etc.

Parameters

columns
Column names or expressions to select. Use * for all columns, or specify individual column names. Supports SQL functions like COUNT(), SUM(), GROUP_CONCAT().
table
A catalog table name (e.g., ENTITIES, MICROFLOWS). Must be prefixed with CATALOG..
condition
A SQL WHERE clause for filtering rows. Supports standard operators (=, LIKE, IN, AND, OR) and subqueries.
column
Column to sort by. Append ASC (default) or DESC for sort direction.
n
Maximum number of rows to return.

Examples

List all entities

SELECT Name, Module FROM CATALOG.ENTITIES;

Find microflows in a specific module

SELECT Name FROM CATALOG.MICROFLOWS WHERE Module = 'Sales' ORDER BY Name;

Count entities per module

SELECT Module, COUNT(*) AS EntityCount
FROM CATALOG.ENTITIES
GROUP BY Module
ORDER BY EntityCount DESC;

Find entities with many attributes

SELECT e.Module, e.Name, COUNT(a.Name) AS AttrCount
FROM CATALOG.ENTITIES e
JOIN CATALOG.ATTRIBUTES a ON e.Id = a.EntityId
GROUP BY e.Module, e.Name
HAVING COUNT(a.Name) > 10
ORDER BY AttrCount DESC;

Find many-to-many associations

SELECT Name, ParentEntity, ChildEntity
FROM CATALOG.ASSOCIATIONS
WHERE Type = 'ReferenceSet';

Find pages with no microflow references

SELECT p.Module, p.Name
FROM CATALOG.PAGES p
WHERE p.Id NOT IN (
  SELECT DISTINCT TargetId FROM CATALOG.REFS WHERE RefKind = 'page'
);

Limit results

SELECT Name FROM CATALOG.MICROFLOWS LIMIT 10;

See Also

REFRESH CATALOG, LIST CATALOG TABLES, SEARCH

LIST CALLERS / CALLEES

Synopsis

LIST CALLERS OF qualified_name [ TRANSITIVE ]

LIST CALLEES OF qualified_name [ TRANSITIVE ]

Description

Displays the call relationships for a given element. LIST CALLERS finds all elements that call or reference the specified element (incoming edges). LIST CALLEES finds all elements that the specified element calls or references (outgoing edges).

These commands require REFRESH CATALOG FULL to have been run beforehand. Without it, the cross-reference data needed for caller/callee analysis is not available.

By default, only direct (one-hop) relationships are shown. With the TRANSITIVE option, the command follows the call chain recursively to show the full transitive closure – all indirect callers or callees at any depth.

Parameters

qualified_name
The fully qualified name of the element to analyze (e.g., Module.MicroflowName, Module.EntityName). Works with microflows, nanoflows, pages, snippets, entities, and other referenceable elements.
TRANSITIVE
Follow the call chain recursively. For LIST CALLERS, this finds everything that directly or indirectly leads to the target. For LIST CALLEES, this finds everything the target directly or indirectly depends on.

Examples

Find direct callers of a microflow

REFRESH CATALOG FULL;
LIST CALLERS OF Sales.ACT_CreateOrder;

Find all transitive callers

LIST CALLERS OF Sales.ACT_CreateOrder TRANSITIVE;

Find what a microflow calls

LIST CALLEES OF Sales.ACT_ProcessOrder;

Find all transitive callees

LIST CALLEES OF Sales.ACT_ProcessOrder TRANSITIVE;

From the command line

-- Shell commands:
-- mxcli callers -p app.mpr Sales.ACT_CreateOrder
-- mxcli callers -p app.mpr Sales.ACT_CreateOrder --transitive
-- mxcli callees -p app.mpr Sales.ACT_ProcessOrder

See Also

LIST REFERENCES / IMPACT / CONTEXT, REFRESH CATALOG, SELECT FROM CATALOG

LIST REFERENCES / IMPACT, DESCRIBE CONTEXT

Synopsis

LIST REFERENCES TO qualified_name

LIST IMPACT OF qualified_name

DESCRIBE CONTEXT OF qualified_name [ DEPTH n ]

Description

These commands provide different views of cross-reference information for a given element. All three require REFRESH CATALOG FULL to have been run beforehand.

LIST REFERENCES TO lists all elements that reference the specified element. This includes microflows that use an entity, pages that display it, associations that connect to it, and any other form of reference.

LIST IMPACT OF performs an impact analysis showing what would be affected if the specified element were changed or removed. This is broader than LIST REFERENCES as it considers transitive dependencies and indirect effects.

DESCRIBE CONTEXT OF assembles the surrounding context of an element – its definition, its callers, callees, and related elements – suitable for providing to an LLM or for understanding an element in its broader project context. The optional DEPTH parameter controls how many levels of related elements to include.

Attributes, enumerations and enumeration values

The target may also be an attribute (Module.Entity.Attribute), an enumeration, or an enumeration value (Module.Enum.Value). The graph records:

KindMeaning
membera microflow, nanoflow, rule, page, snippet, workflow or import/export mapping binds, reads or writes the attribute, or navigates the association
xpathan XPath constraint names the attribute or association, or compares an enumeration attribute with the value
typean attribute, parameter or variable is typed as the enumeration
valuean expression names the enumeration value
mappingan import or export mapping maps the entity
splita microflow, nanoflow or rule branches on an object being the entity (an inheritance split)

IMPACT OF an enumeration includes the uses of each of its values, with a Target column naming which one.

Results list each (source, kind) once, and the IMPACT summary counts distinct elements.

When nothing is found, the message says what was searched. An attribute named only through a variable in a free-text expression ($Order/Total), and an enumeration value used only as a decision branch, are not resolved by the catalog, so an empty result for an attribute or a value tells you to run SEARCH before treating it as unused.

Parameters

qualified_name
The fully qualified name of the element to analyze (e.g., Module.EntityName, Module.MicroflowName, Module.Entity.Attribute, Module.Enum.Value).
n (CONTEXT only)
The number of levels of related elements to include. Defaults to 1 if not specified. Higher values include more surrounding context but produce more output.

Examples

Find all references to an entity

REFRESH CATALOG FULL;
LIST REFERENCES TO Sales.Customer;

Analyze impact before making changes

LIST IMPACT OF Sales.Customer;

Check an attribute before dropping it

LIST IMPACT OF Sales.Order.DiscountCode;

Gather context for a microflow

DESCRIBE CONTEXT OF Sales.ACT_CreateOrder;

Gather deeper context

DESCRIBE CONTEXT OF Sales.ACT_CreateOrder DEPTH 3;

Check impact before moving an element

mdl 1;
LIST IMPACT OF Sales.CustomerEdit;
MOVE PAGE Sales.CustomerEdit TO NewModule;

From the command line

-- Shell commands:
-- mxcli refs -p app.mpr Sales.Customer
-- mxcli impact -p app.mpr Sales.Customer
-- mxcli context -p app.mpr Sales.ACT_CreateOrder --depth 3

See Also

LIST CALLERS / CALLEES, REFRESH CATALOG, SELECT FROM CATALOG

LIST CATALOG TABLES

Synopsis

LIST CATALOG TABLES

Description

Lists all available catalog tables along with their column names and types. This is useful for discovering what metadata is queryable before writing SELECT FROM CATALOG queries.

The output includes both basic tables (populated by REFRESH CATALOG) and extended tables (populated by REFRESH CATALOG FULL). Tables that require a full refresh are marked accordingly.

Parameters

This statement takes no parameters.

Examples

List all catalog tables

LIST CATALOG TABLES;

Typical workflow: discover then query

REFRESH CATALOG;
LIST CATALOG TABLES;
SELECT * FROM CATALOG.ENTITIES LIMIT 5;

See Also

REFRESH CATALOG, SELECT FROM CATALOG

External SQL Statements

Statements for connecting to and querying external databases (PostgreSQL, Oracle, SQL Server). Credentials are isolated from session output and logs – the DSN is never displayed after the initial connection.

External SQL connections are independent of the Mendix project connection. You can have multiple external database connections open simultaneously, each identified by an alias.

StatementDescription
SQL CONNECTOpen a connection to an external database
SQL DISCONNECTClose an external database connection
SQL (query)Execute SQL queries and schema discovery commands
SQL GENERATE CONNECTORGenerate Database Connector MDL from an external schema
IMPORT FROMImport data from an external database into a Mendix app database

SQL CONNECT

Synopsis

SQL CONNECT driver 'dsn' AS alias

Description

Opens a connection to an external database. The connection is identified by the alias, which is used in all subsequent SQL commands to route queries to the correct database.

Multiple connections can be open simultaneously with different aliases. Use SQL CONNECTIONS to list active connections (shows alias and driver only – the DSN is never displayed for security).

Supported database drivers:

DriverAliasesExample DSN
postgrespg, postgresqlpostgres://user:pass@host:5432/dbname
oracleoraoracle://user:pass@host:1521/service
sqlservermssqlsqlserver://user:pass@host:1433?database=dbname

Parameters

driver
The database driver to use. One of postgres (or pg, postgresql), oracle (or ora), or sqlserver (or mssql).
dsn
The data source name (connection string) enclosed in single quotes. The format depends on the driver. The DSN is stored in memory only and never appears in session output or logs.
alias
A short identifier for this connection. Used in all subsequent SQL alias ... commands. Must be unique among active connections.

Examples

Connect to PostgreSQL

SQL CONNECT postgres 'postgres://user:pass@localhost:5432/mydb' AS source;

Connect to Oracle

SQL CONNECT oracle 'oracle://scott:tiger@dbhost:1521/ORCL' AS erp;

Connect to SQL Server

SQL CONNECT sqlserver 'sqlserver://sa:Password1@localhost:1433?database=northwind' AS legacy;

List active connections

SQL CONNECTIONS;

Connect using driver aliases

SQL CONNECT pg 'postgres://user:pass@localhost:5432/mydb' AS src;
SQL CONNECT mssql 'sqlserver://sa:Pass@host:1433?database=db' AS dst;

See Also

SQL DISCONNECT, SQL (query), SQL GENERATE CONNECTOR

SQL DISCONNECT

Synopsis

SQL DISCONNECT alias

Description

Closes an external database connection identified by the given alias. After disconnecting, the alias is no longer valid and any subsequent SQL alias ... commands will fail.

Use SQL CONNECTIONS to see which connections are currently active before disconnecting.

Parameters

alias
The alias of the connection to close. Must match an active connection established with SQL CONNECT.

Examples

Disconnect a connection

SQL DISCONNECT source;

Full connection lifecycle

SQL CONNECT postgres 'postgres://user:pass@localhost:5432/mydb' AS source;
SQL source SHOW TABLES;
SQL source SELECT COUNT(*) FROM users;
SQL DISCONNECT source;

See Also

SQL CONNECT, SQL (query)

SQL (query)

Synopsis

SQL alias query_text

SQL alias SHOW TABLES
SQL alias SHOW VIEWS
SQL alias SHOW FUNCTIONS
SQL alias DESCRIBE table_name

Description

Executes a SQL query or schema discovery command against an external database connection. The alias identifies which connection to use (established with SQL CONNECT).

Raw SQL passthrough: Any SQL text after the alias is sent directly to the external database. The results are displayed as a formatted table. This supports SELECT, INSERT, UPDATE, DELETE, DDL, and any other SQL the target database accepts.

Schema discovery commands provide a portable way to explore the database schema without writing database-specific SQL:

CommandDescription
SHOW TABLESLists user tables in the database
SHOW VIEWSLists user views in the database
SHOW FUNCTIONSLists functions and stored procedures
DESCRIBE table_nameShows columns, data types, and nullability for a table

The schema discovery commands query information_schema internally and work consistently across PostgreSQL, Oracle, and SQL Server.

Parameters

alias
The connection alias established with SQL CONNECT.
query_text
Any valid SQL for the target database. Sent as-is to the database engine.
table_name (DESCRIBE only)
The name of the table or view to describe.

Examples

Query data

SQL source SELECT * FROM users WHERE active = true LIMIT 10;

List tables

SQL source SHOW TABLES;

List views

SQL source SHOW VIEWS;

Describe a table

SQL source DESCRIBE users;

Insert data

SQL source INSERT INTO audit_log (action, timestamp) VALUES ('export', NOW());

Run aggregate queries

SQL source SELECT department, COUNT(*) AS headcount
  FROM employees
  GROUP BY department
  ORDER BY headcount DESC;

From the command line

-- Shell command:
-- mxcli sql --driver postgres --dsn 'postgres://...' "SELECT * FROM users"

See Also

SQL CONNECT, SQL DISCONNECT, SQL GENERATE CONNECTOR, IMPORT FROM

SQL GENERATE CONNECTOR

Synopsis

SQL alias GENERATE CONNECTOR INTO module [ TABLES ( table [, ...] ) ] [ VIEWS ( view [, ...] ) ] [ EXEC ]

Description

Generates MDL statements that create a Mendix Database Connector module from the schema of an external database. The generated MDL includes entities, attributes, and associations that mirror the external database tables and views, configured for use with the Mendix Database Connector module.

Without EXEC, the command outputs the generated MDL to the console for review. With EXEC, it executes the MDL immediately against the open project.

By default, all user tables are included. Use TABLES (...) and VIEWS (...) to limit generation to specific tables and views.

The type mapping from SQL types to Mendix types is handled automatically (e.g., VARCHAR becomes String, INTEGER becomes Integer, TIMESTAMP becomes DateTime).

Parameters

alias
The connection alias established with SQL CONNECT.
module
The target Mendix module where the connector entities will be created.
table
One or more table names to include. If omitted, all user tables are included.
view
One or more view names to include. If omitted, no views are included unless TABLES is also omitted (in which case all tables are included by default).
EXEC
Execute the generated MDL immediately instead of printing it. Requires an open Mendix project.

Examples

Preview generated MDL for all tables

SQL source GENERATE CONNECTOR INTO HRModule;

Generate for specific tables

SQL source GENERATE CONNECTOR INTO HRModule TABLES (employees, departments);

Generate for tables and views

SQL source GENERATE CONNECTOR INTO HRModule TABLES (employees) VIEWS (employee_summary);

Generate and execute immediately

SQL source GENERATE CONNECTOR INTO HRModule TABLES (employees, departments) EXEC;

Full workflow

SQL CONNECT postgres 'postgres://user:pass@localhost:5432/hrdb' AS source;
SQL source SHOW TABLES;
SQL source DESCRIBE employees;
SQL source GENERATE CONNECTOR INTO HRModule TABLES (employees, departments) EXEC;
SQL DISCONNECT source;

See Also

SQL CONNECT, SQL (query), IMPORT FROM

IMPORT FROM

Synopsis

IMPORT FROM alias QUERY 'sql'
  INTO module.Entity
  MAP ( source_col AS AttrName [, ...] )
  [ LINK ( source_col TO AssocName ON MatchAttr ) [, ...] ]
  [ BATCH size ]
  [ LIMIT count ]

Description

Imports data from an external database into a Mendix application’s PostgreSQL database. The command executes the specified SQL query against the external connection, maps the result columns to entity attributes, and inserts rows in batches using direct database insertion.

The import pipeline handles:

  • Mendix ID generation: Automatically generates unique Mendix object IDs for each inserted row, following the Mendix ID format and sequence tracking.
  • Batch insertion: Rows are inserted in configurable batch sizes for performance.
  • Association linking: The LINK clause matches imported rows to existing objects via an association, looking up the target by a matching attribute value.

The target Mendix application database is auto-detected from the project’s database configuration. The import connects directly to the application’s PostgreSQL database.

Parameters

alias
The external database connection alias established with SQL CONNECT.
sql
A SQL query string (in single quotes) to execute against the external database. The result set columns are available for mapping.
module.Entity
The fully qualified Mendix entity to import into (e.g., HR.Employee).
source_col
A column name from the SQL query result set.
AttrName
The target attribute name on the Mendix entity.
The name of the association to set. The source column value is matched against the MatchAttr on the associated entity to find the target object.
The attribute on the associated entity used to look up the target object for linking.
size (BATCH only)
Number of rows per insert batch. Defaults to a reasonable batch size if not specified.
count (LIMIT only)
Maximum number of rows to import from the query result.

Examples

Basic import

IMPORT FROM source QUERY 'SELECT name, email FROM employees'
  INTO HR.Employee
  MAP (name AS Name, email AS Email);

Import with association linking

IMPORT FROM source QUERY 'SELECT name, email, dept_name FROM employees'
  INTO HR.Employee
  MAP (name AS Name, email AS Email)
  LINK (dept_name TO Employee_Department ON Name);

Import with batch size and limit

IMPORT FROM source QUERY 'SELECT name, email, dept_name FROM employees'
  INTO HR.Employee
  MAP (name AS Name, email AS Email)
  LINK (dept_name TO Employee_Department ON Name)
  BATCH 500
  LIMIT 1000;

Full workflow: connect, explore, import

SQL CONNECT postgres 'postgres://user:pass@legacydb:5432/hr' AS source;
SQL source SHOW TABLES;
SQL source DESCRIBE employees;

IMPORT FROM source QUERY 'SELECT name, email FROM employees WHERE active = true'
  INTO HR.Employee
  MAP (name AS Name, email AS Email)
  BATCH 500;

SQL DISCONNECT source;

See Also

SQL CONNECT, SQL (query), SQL GENERATE CONNECTOR

Settings Statements

Statements for viewing and modifying Mendix project settings. Settings are organized into categories: MODEL (application-level settings), CONFIGURATION (runtime configurations), CONSTANT (constant value overrides per configuration), LANGUAGE (localization settings), and WORKFLOWS (workflow engine settings).

StatementDescription
SHOW / DESCRIBE SETTINGSView current project settings
ALTER SETTINGSModify project settings by category

LIST / DESCRIBE SETTINGS

Synopsis

LIST SETTINGS

DESCRIBE SETTINGS

DESCRIBE SETTINGS CONFIGURATION 'name'

Description

Displays project settings. LIST SETTINGS provides a compact overview, one row per section. DESCRIBE SETTINGS outputs every section as re-executable MDL — the ALTER SETTINGS and CREATE OR MODIFY CONFIGURATION statements that recreate it. DESCRIBE SETTINGS CONFIGURATION 'name' outputs one configuration only, and names the ones that exist when there is none by that name. There is no form that describes another single section.

Both also show the project ID — the $ID of the Projects$Project root unit that every module belongs to. LIST SETTINGS prints it as a Project row; DESCRIBE SETTINGS prints it as a -- Project ID: … comment, so the output still executes. It is read-only: there is no statement that changes it. Business Events uses it as the app id, so two apps made from one model (a copied .mpr, a shared template) report the same project ID.

DESCRIBE SETTINGS emits, in order:

SectionEmitted as
RuntimeALTER SETTINGS RUNTIME (…) — AfterStartupMicroflow, HashAlgorithm, JavaVersion, etc.; only the properties this project’s Mendix version stores.
ConfigurationsCREATE OR MODIFY CONFIGURATION 'name' (…) per configuration — DatabaseType, DatabaseUrl, HttpPortNumber, etc. (never DatabasePassword), then ALTER SETTINGS CONSTANT … IN CONFIGURATION 'name' per constant override. A private override — its value kept on the developer’s workstation, not in the shared model — is a comment, since its value is not in the project.
LanguagesALTER SETTINGS LANGUAGE ADD OR MODIFY 'code' (…) per enabled language, then the default language.
WorkflowsALTER SETTINGS WORKFLOWS (…) and one ADD OR MODIFY GROUP per workflow group.
Web UIALTER SETTINGS WEBUI (OptimizedClient: …) — Yes (React client), No (classic client) or MigrationMode.

Examples

Show settings overview

LIST SETTINGS;

Describe all settings in MDL format

DESCRIBE SETTINGS;

Describe one configuration

DESCRIBE SETTINGS CONFIGURATION 'Default';

List workflow groups

LIST WORKFLOW GROUPS;

Lists the workflow groups from App Settings ▸ Workflows ▸ Groups with their descriptions. It reads the project settings directly, so no catalog refresh is needed. Groups are created with ALTER SETTINGS WORKFLOWS ... GROUP.

See Also

ALTER SETTINGS, LIST CONSTANT VALUES

ALTER SETTINGS

Synopsis

ALTER SETTINGS RUNTIME ( key: value, ... )

ALTER SETTINGS CONFIGURATION 'name' ( key: value, ... )

ALTER SETTINGS CONSTANT @Module.Name VALUE 'value' IN CONFIGURATION 'config'

ALTER SETTINGS DROP CONSTANT @Module.Name IN CONFIGURATION 'config'

CREATE CONFIGURATION 'name' [( key: value, ... )]

DROP CONFIGURATION 'name'

ALTER SETTINGS LANGUAGE ( key: value, ... )
ALTER SETTINGS LANGUAGE ADD [OR MODIFY] 'code' [( option: value, ... )]
ALTER SETTINGS LANGUAGE MODIFY 'code' ( option: value, ... )
ALTER SETTINGS LANGUAGE DROP 'code' 

ALTER SETTINGS WORKFLOWS ( key: value, ... )
ALTER SETTINGS WORKFLOWS ADD [OR MODIFY] GROUP 'name' [( Description: 'text' )]
ALTER SETTINGS WORKFLOWS MODIFY GROUP 'name' ( Description: 'text' )
ALTER SETTINGS WORKFLOWS DROP GROUP 'name'

ALTER SETTINGS WEBUI ( OptimizedClient: 'Yes' | 'No' | 'MigrationMode' )

Description

Modifies project settings by category. Each category has its own syntax and available keys. Properties are written Key: value in a parenthesised list, as in every other MDL statement; the older key = value, ... without parentheses still runs and warns MDL-DEPR060, and mxcli fmt --upgrade rewrites it.

MODEL settings control application-level behavior such as the after-startup microflow, hashing algorithm, and Java version.

CONFIGURATION settings control named runtime configurations. Each project can have multiple configurations (e.g., default, staging, production). The keys are DatabaseType, DatabaseUrl, DatabaseName, DatabaseUserName, DatabasePassword, HttpPortNumber, ServerPortNumber, ApplicationRootUrl, ExtraJvmParameters (Studio Pro’s Server ▸ Extra JVM parameters) and MaxJavaHeapSize (Server ▸ Java heap ▸ Maximum size, in MB; 0 is Studio Pro’s “Default”). The configuration name must be enclosed in single quotes.

CONSTANT settings override the default value of a project constant within a specific runtime configuration. The constant is named as MDL names a constant everywhere, @Module.Name; the configuration name is enclosed in single quotes. The older quoted constant name (CONSTANT 'Module.Name') still parses and warns MDL-DEPR085; mxcli fmt --upgrade rewrites it.

LANGUAGE settings control localization: the default language code, and the list of enabled languages — the only ones a build emits translations for.

WORKFLOWS settings control the workflow engine, including the user entity used for workflow tasks and default task parallelism, and the workflow groups — the named buckets under App Settings ▸ Workflows ▸ Groups that a user task’s group targeting selects from. Groups need Mendix 11.2 or later.

WEBUI settings choose the client the app runs on: OptimizedClient: 'Yes' is the React client, 'No' the classic (Dojo) client, 'MigrationMode' Mendix’s mixed mode. The value is matched case-insensitively; anything else is rejected by mxcli check (MDL-SET03) and by the executor.

A section or key that does not exist is rejected by mxcli check (MDL-SET04) with the message the executor gives, so a script that cannot run does not pass the check.

Parameters

key
The setting name to modify. Available keys depend on the category.
value
The new value for the setting. String values must be enclosed in single quotes.
name (CONFIGURATION)
The name of the configuration, enclosed in single quotes.
Module.Name (CONSTANT)
The constant, written @Module.Name.
config (CONSTANT only)
The name of the runtime configuration where the constant override applies, enclosed in single quotes.

Examples

Set the after-startup microflow

ALTER SETTINGS RUNTIME ( AfterStartupMicroflow: 'MyModule.ACT_Startup' );

The after-startup microflow must return Boolean. Mendix build fails with CE0142 if it has no return type. A common trip-up is wiring a void seed/demo-data microflow to after-startup — end it with return true so it returns a Boolean.

Configure database type

ALTER SETTINGS CONFIGURATION 'default' ( DatabaseType: 'POSTGRESQL' );

Set database URL for a configuration

ALTER SETTINGS CONFIGURATION 'production' ( DatabaseUrl: 'jdbc:postgresql://dbhost:5432/myapp' );

Pass extra JVM parameters

ALTER SETTINGS CONFIGURATION 'default' ( ExtraJvmParameters: '-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=8888' );

The value is stored as written and is what Studio Pro passes to the JVM when it runs the app. mxcli run --local and mxcli test --local pass it too, after -Xmx<n>m for a non-zero MaxJavaHeapSize, and print which options they pass (without -D values).

Override a constant in a configuration

ALTER SETTINGS CONSTANT @MyModule.ApiBaseUrl VALUE 'https://api.staging.example.com' IN CONFIGURATION 'staging';

An override’s value is either shared — stored in the model, and so in version control — or private, stored on the developer’s own workstation and kept out of the repository (the usual choice for development API tokens).

MDL preserves that choice but never changes it. ALTER SETTINGS CONSTANT ... VALUE applies to shared values only; on a private override it is refused, because setting a value would convert it to a shared one, publish a deliberately-local value into version control, and break the developer’s local binding. Change the constant to a shared value in Studio Pro first, or drop the override. DESCRIBE SETTINGS reports a private override as a comment rather than a re-executable statement, for the same reason.

Set the default language

The code must already be enabled; ALTER SETTINGS LANGUAGE DefaultLanguageCode is validated against the enabled list.

ALTER SETTINGS LANGUAGE ( DefaultLanguageCode: 'en_US' );

Enable, change and disable a language

mdl 1;
ALTER SETTINGS LANGUAGE ADD 'de_DE';
ALTER SETTINGS LANGUAGE ADD 'ar_SD' (CheckCompleteness: true, CustomDateFormat: 'yyyy-MM-dd');
ALTER SETTINGS LANGUAGE ADD OR MODIFY 'de_DE' (CheckCompleteness: true);
ALTER SETTINGS LANGUAGE MODIFY 'de_DE' (CustomDateFormat: 'dd.MM.yyyy');
ALTER SETTINGS LANGUAGE DROP 'de_DE';

Options: CheckCompleteness (report errors for texts with no translation in this language — the default language is always checked regardless), CustomDateFormat, CustomTimeFormat, CustomDateTimeFormat.

MODIFY touches only the options it names, so turning on the completeness check cannot clear a custom format set elsewhere. ADD OR MODIFY is the upsert and is what DESCRIBE SETTINGS emits.

Removing the default language is refused. Removing any other language does not delete its translations — they stay in the model, stop being built, and the run reports how many source strings are affected.

Configure workflow user entity

ALTER SETTINGS WORKFLOWS ( UserEntity: 'Administration.Account' );

Manage workflow groups

mdl 1;
ALTER SETTINGS WORKFLOWS ADD GROUP 'Approvers' (Description: 'Primary approval group');
ALTER SETTINGS WORKFLOWS ADD GROUP 'Reviewers';

-- the upsert, and what DESCRIBE SETTINGS emits
ALTER SETTINGS WORKFLOWS ADD OR MODIFY GROUP 'Approvers' (Description: 'Approves budget requests');

-- changes only the options it names
ALTER SETTINGS WORKFLOWS MODIFY GROUP 'Reviewers' (Description: 'Second-line review');

ALTER SETTINGS WORKFLOWS DROP GROUP 'Reviewers';

Description is the only option, because a workflow group stores a name and a description and nothing else. The name is the group’s identity, which is what MODIFY and REMOVE address; adding a second group whose name differs only in case is refused rather than creating one no statement could address unambiguously.

Changing a description keeps the group’s stored identity, so the System.WorkflowGroup row the runtime maintains for it is updated in place — group memberships and already-assigned user tasks survive the edit.

REMOVE has no dangling reference to check for: nothing in the model points at a settings group, since a user task targets groups through a microflow or an XPath returning System.WorkflowGroup objects. The coupling is at runtime, where the removed group’s row simply stops being maintained.

List the groups with LIST WORKFLOW GROUPS.

Set Java version

ALTER SETTINGS RUNTIME ( JavaVersion: '17' );

Switch to the classic web client

ALTER SETTINGS WEBUI ( OptimizedClient: 'No' );

Stored as UseOptimizedClient on the Web UI settings part (Mendix 9.10 and later). DESCRIBE SETTINGS emits the statement, and LIST SETTINGS shows the value as OptimizedClient.

Remove a constant override from a configuration

ALTER SETTINGS DROP CONSTANT @MyModule.ApiBaseUrl IN CONFIGURATION 'staging';

Create a new configuration

CREATE CONFIGURATION 'Staging' (
  DatabaseType: 'PostgreSql',
  DatabaseUrl: 'staging-db:5432',
  HttpPortNumber: 8080
);

DatabaseType must name a Mendix database type — Db2, Hsqldb, MySql, Oracle, PostgreSql, SapHana or SqlServer. The value is matched case-insensitively and stored in the spelling above; anything else is rejected by mxcli check and by the executor. A configuration created without properties gets Studio Pro’s defaults: Hsqldb, runtime port 8080, admin port 8090.

Drop a configuration

DROP CONFIGURATION 'Staging';

See Also

SHOW / DESCRIBE SETTINGS

Organization Statements

Statements for organizing project structure: creating modules and folders, and moving documents between them.

StatementDescription
CREATE MODULECreate a new module in the project
CREATE FOLDERCreate a folder within a module for organizing documents
DROP FOLDERDrop an empty folder from a module
MOVEMove a document to a different module or folder
MOVE FOLDERMove a folder to another location or module

CREATE MODULE

Synopsis

CREATE [OR MODIFY] MODULE module_name [ ( Version: 'x.y.z', ModuleType: app | addon ) ]

Description

Creates a new module in the Mendix project. Modules are the top-level organizational unit for all project documents including entities, microflows, pages, and enumerations. The module is created with an empty domain model and Studio Pro’s defaults for a new module: an app module, version 1.0.0.

The optional property list sets the module’s settings, the General tab of Studio Pro’s Module settings dialog. CREATE OR MODIFY applies them to a module that already exists, changing only the settings it names. A plain CREATE of an existing module leaves it unchanged. To change one setting, use ALTER MODULE … SET.

Parameters

module_name
The name of the module to create. Must be a valid identifier (letters, digits, underscores; cannot start with a digit). If the name conflicts with a reserved keyword, enclose it in double quotes.
Version
The Module version, a SemVer string 'major.minor.patch' with an optional pre-release or build suffix ('1.0.0-rc.1'). It is the version an exported .mpk carries. Anything else is refused, as the dialog refuses it.
ModuleType
app (an App module, the default) or addon (an Add-on module). An add-on hides every element not marked Usable from the rest of the app: referencing one from another module is CE1406, which check -p --references reports. Pages and snippets in an add-on cannot be made Usable at all: showing such a page, or calling such a snippet, from another module is CE1406 whatever export level it stores. Add-on modules need Mendix 9.12 or later. Solution modules have no MDL spelling.

Settings shown by DESCRIBE MODULE

DESCRIBE MODULE prints the module’s CREATE OR MODIFY statement with the settings that differ from a new module’s, so its output re-runs:

create or modify module Inventory (
  Version: '2.3.1',
  ModuleType: addon,
);
-- location: Marketplace modules (set in Studio Pro)
-- add-on module: elements not marked Usable cannot be referenced from other modules

The Location (Module list or Marketplace modules) is printed as a comment when it is Marketplace modules; it cannot be set from MDL yet. The Module ID and the checksum of an imported module are not shown: Studio Pro owns them, and setting a module’s settings keeps them.

LIST MODULES shows each module’s version and type in its Version and Type columns.

Examples

Create a module

CREATE MODULE OrderManagement;

Create multiple modules for a layered architecture

mdl 1;
CREATE MODULE Sales;
CREATE MODULE Inventory;
CREATE MODULE Reporting;

Create a versioned add-on module

mdl 1;
create or modify module Inventory (
  Version: '2.3.1',
  ModuleType: addon,
);

See Also

ALTER MODULE … SET, CREATE FOLDER, MOVE, DROP MODULE

ALTER MODULE … SET

Synopsis

ALTER MODULE module_name SET ( Version: 'x.y.z', ModuleType: app | addon )

Description

Changes a module’s settings, the General tab of Studio Pro’s Module settings dialog. It takes the same keys as CREATE MODULE, and changes only the settings it names. The rest of the module’s settings, including its Module ID and an imported module’s checksum, keep their stored values. A statement that restates the stored settings writes nothing.

KeyValuesStudio Pro field
Versiona SemVer string, '2.3.1'Module version
ModuleTypeapp | addonModule type

An unknown key, a key named twice, a version that is not SemVer, or a type other than app/addon is an error from both check and exec. ModuleType: addon needs Mendix 9.12 or later.

Making a module an add-on hides every element not marked Usable from the rest of the app: a reference from another module becomes CE1406. Run mxcli check script.mdl -p app.mpr --references to see which references that affects.

Examples

mdl 1;
-- Bump the version before exporting the module
alter module Inventory set (Version: '2.4.0');

-- Make it an add-on module
alter module Inventory set (ModuleType: addon);

-- Both at once
alter module Inventory set (Version: '3.0.0', ModuleType: app);

See Also

CREATE MODULE, ALTER MODULE … JAR DEPENDENCY

CREATE FOLDER

Synopsis

CREATE FOLDER module_name/folder_path

Description

Creates a folder within a module for organizing documents such as pages, microflows, and snippets. Nested folders use the / separator. If intermediate folders in the path do not exist, they are created automatically.

Note: Folders can also be created implicitly by specifying a FOLDER clause when creating a microflow or a Folder property when creating a page.

Parameters

module_name
The name of the module in which to create the folder.
folder_path
The path of the folder to create within the module. Use / to create nested folders (e.g., Orders/Processing).

Examples

Create a simple folder

CREATE FOLDER MyModule/Pages;

Create a nested folder

CREATE FOLDER MyModule/Orders/Processing;

Use a folder when creating a microflow

CREATE MICROFLOW MyModule.ACT_ProcessOrder
FOLDER 'Orders/Processing'
BEGIN
  RETURN true;
END;

Use a folder when creating a page

CREATE PAGE MyModule.Order_Edit FOLDER 'Orders'
(
  Title: 'Edit Order',
  Layout: Atlas_Core.PopupLayout
)
{
  CONTAINER main {}
};

A folder whose name contains /

Studio Pro allows / inside a folder name, so in a FOLDER '…' path a slash that belongs to the name is written \/ (and a backslash \\). This is one folder, Private - String en/de-cryption, holding Apis:

CREATE CONSTANT Encryption.EncryptionKey FOLDER 'Private - String en\/de-cryption/Apis' (
  Type: String,
  DefaultValue: ''
);

DESCRIBE writes the escape for you, so its output files the document back where it was. A backslash before any other character is an ordinary character.

See Also

CREATE MODULE, DROP FOLDER, MOVE

DROP FOLDER

Synopsis

DROP FOLDER 'folder_path' IN module_name;

Description

Removes an empty folder from a module. The folder must contain no documents (pages, microflows, etc.) and no sub-folders. If the folder is not empty, the command returns an error indicating how many child units remain.

Nested folder paths use the / separator. Only the leaf folder is deleted; parent folders are not affected.

Parameters

folder_path
The path of the folder to delete. Use / for nested folders (e.g., 'Orders/Processing').
module_name
The name of the module containing the folder.

Examples

Drop a simple folder

DROP FOLDER 'OldPages' IN MyModule;

Drop a nested folder

DROP FOLDER 'Orders/Archive' IN MyModule;

Move contents out first, then drop

mdl 1;
-- Move all documents to module root
MOVE MICROFLOW MyModule.ACT_Process TO MyModule;
MOVE PAGE MyModule.ProcessDetail TO MyModule;

-- Now the folder is empty and can be dropped
DROP FOLDER 'Processing' IN MyModule;

Error Conditions

ErrorCause
folder is not empty: contains N child unit(s)The folder still has documents or sub-folders
folder not found: 'path' in ModuleNo folder exists at the given path in the module
module not found: ModuleThe specified module does not exist

See Also

CREATE FOLDER, CREATE MODULE, MOVE, MOVE FOLDER

MOVE

Synopsis

MOVE document_type qualified_name TO module_name
MOVE document_type qualified_name TO FOLDER 'folder_path'
MOVE document_type qualified_name TO FOLDER 'folder_path' IN module_name

Description

Moves a document to a different folder or module. Every top-level document type is supported, spelled the way DESCRIBE spells it. When moving to a folder, missing intermediate folders are created automatically. Cross-module moves change the qualified name of the document, which may break by-name references elsewhere in the project.

Entity moves only support moving to a module (not to a folder), because entities are embedded in domain model documents.

A Java or JavaScript action keeps its source in a directory named after its module (javasource/<module>/actions/, javascriptsource/<module>/actions/). A cross-module move takes the source file along, and rewrites a Java source’s package line the way mxbuild would, so the action keeps its code. A source left behind builds without error, but the app then calls a generated stub that throws “not implemented”. If the target module’s directory already holds a source file of that name, the move is refused before anything changes. A move within the module, into a folder, leaves the source where it is.

A FOLDER clause on CREATE OR MODIFY moves an existing document as well, so a script that already creates a document does not need a separate MOVE to place it.

Parameters

document_type
The type of document to move:
GroupTypes
PagesPAGE, SNIPPET, BUILDING BLOCK, LAYOUT, MENU
LogicMICROFLOW, NANOFLOW, WORKFLOW, TASK QUEUE, SCHEDULED EVENT
DomainENUMERATION, CONSTANT, REGULAR EXPRESSION, ENTITY
MappingsJSON STRUCTURE, IMPORT MAPPING, EXPORT MAPPING
CodeJAVA ACTION, JAVASCRIPT ACTION, DATABASE CONNECTION, DATA TRANSFORMER
ResourcesIMAGE COLLECTION, ICON COLLECTION
IntegrationCONSUMED REST SERVICE, PUBLISHED REST SERVICE, CONSUMED ODATA SERVICE, PUBLISHED ODATA SERVICE, BUSINESS EVENT SERVICE
AIMODEL, AGENT, KNOWLEDGE BASE, CONSUMED MCP SERVICE

FOLDER moves a folder rather than a document — see the example below.

If the named document turns out to be a different type, the statement is refused and the error names what it actually is.

qualified_name
The current Module.Name of the document to move.
module_name
The target module. When specified without FOLDER, moves the document to the module root.
folder_path
The target folder path within the module. Use / for nested paths (e.g., 'Orders/Processing'). Enclosed in single quotes.

Examples

Move a page to a folder in the same module

MOVE PAGE MyModule.CustomerEdit TO FOLDER 'Customers';

Move a microflow to a nested folder

MOVE MICROFLOW MyModule.ACT_ProcessOrder TO FOLDER 'Orders/Processing';

Move a snippet to a different module

MOVE SNIPPET OldModule.NavigationMenu TO Common;

Move an entity to a different module

MOVE ENTITY OldModule.Customer TO NewModule;

Move a page to a folder in a different module

MOVE PAGE OldModule.CustomerPage TO FOLDER 'Screens' IN NewModule;

Move a mapping or JSON structure

mdl 1;
MOVE IMPORT MAPPING MyModule.IMM_Order TO FOLDER 'Private/Import mappings';
MOVE EXPORT MAPPING MyModule.EXM_Order TO FOLDER 'Private/Export mappings';
MOVE JSON STRUCTURE MyModule.JSON_Order TO FOLDER 'Private/JSON structures';

Place a document while creating it

Every document type takes a FOLDER clause on CREATE, so a document can be placed by the statement that creates it. On microflows and nanoflows it follows the signature, before BEGIN; on everything else, pages and snippets included, it follows the qualified name straight away. The Folder: 'path' property that pages, snippets and REST/OData services also take is a deprecated alias (MDL-DEPR105):

mdl 1;
CREATE OR MODIFY JSON STRUCTURE MyModule.JSON_Order
  FOLDER 'Private/JSON structures'
  SAMPLE '{"id": 1}';

CREATE TASK QUEUE MyModule.Q_Orders FOLDER 'Private/Queues' ( Parallelism: 3 );

CREATE JAVA ACTION MyModule.JA_Sync FOLDER 'Private/Java' ()
  RETURNS String AS $$return null;$$;

The clause applies to an existing document too, so re-running a script that gained one files the document rather than leaving it where it was. Omitting the clause leaves the document where it is; it never returns it to the module root.

DESCRIBE emits the clause, so a description replays into the same folder.

Move a folder

MOVE FOLDER MyModule.OldName TO FOLDER 'Archive';

Check impact before a cross-module move

mdl 1;
LIST IMPACT OF OldModule.Customer;
MOVE ENTITY OldModule.Customer TO NewModule;

See Also

CREATE MODULE, CREATE FOLDER

MOVE FOLDER

Synopsis

MOVE FOLDER qualified_name TO FOLDER 'folder_path';
MOVE FOLDER qualified_name TO FOLDER 'folder_path' IN module_name;
MOVE FOLDER qualified_name TO module_name;

Description

Moves a folder (and all its contents) to a different location: another folder within the same module, a folder in a different module, or a module root. The folder’s contents (documents and sub-folders) move with it.

The source folder is identified using the standard qualified name syntax: Module.FolderName. For nested folder paths containing /, use double quotes: Module."Parent/Child".

Target folders are created automatically if they don’t exist.

Parameters

qualified_name
The source folder in Module.FolderName format. Use double quotes for paths with / or special characters.
folder_path
The target folder path. Use / for nested folders.
module_name
The target module name (for cross-module moves).

Examples

Move a folder into another folder

MOVE FOLDER MyModule.Resources TO FOLDER 'Archive';

Move a nested folder to module root

MOVE FOLDER MyModule."Orders/Archive" TO MyModule;

Move a folder to a different module

MOVE FOLDER MyModule.SharedWidgets TO CommonModule;

Move a folder into a folder in another module

MOVE FOLDER MyModule.Templates TO FOLDER 'Shared/Templates' IN CommonModule;

See Also

CREATE FOLDER, DROP FOLDER, MOVE

JAR Dependency Statements

Statements for managing Maven/JAR dependencies declared in a module’s settings. These map to the Module Settings → Java dependencies tab in Studio Pro and are stored as Projects$JarDependency entries in Projects$ModuleSettings.

StatementDescription
ALTER MODULE … JAR DEPENDENCYAdd, update, or remove JAR dependencies
LIST / DESCRIBE JAR DEPENDENCYList or inspect JAR dependencies

ALTER MODULE … JAR DEPENDENCY

Synopsis

ALTER MODULE module_name
  ADD JAR DEPENDENCY (
    group    = 'groupId',
    artifact = 'artifactId',
    version  = 'version',
    included = true|false,
  );

ALTER MODULE module_name
  SET JAR DEPENDENCY 'group:artifact' VERSION 'new-version';

ALTER MODULE module_name
  SET JAR DEPENDENCY 'group:artifact' INCLUDED true|false;

ALTER MODULE module_name
  SET JAR DEPENDENCY 'group:artifact' ADD EXCLUSION 'group:artifact';

ALTER MODULE module_name
  SET JAR DEPENDENCY 'group:artifact' DROP EXCLUSION 'group:artifact';

ALTER MODULE module_name
  DROP JAR DEPENDENCY 'group:artifact';

Description

Manages Maven/JAR dependencies stored in a module’s Projects$ModuleSettings document. These are the same dependencies shown in Studio Pro under Module Settings → Java dependencies.

Multiple actions can be chained in a single ALTER MODULE statement.

Parameters

module_name
The name of the module to modify.
‘group:artifact’
The Maven coordinate identifying the dependency, in groupId:artifactId format (no version). Example: 'org.duckdb:duckdb_jdbc'.
group, artifact, version
The three components of a Maven coordinate. All three are required for ADD.
included
When false, the dependency is declared but excluded from the classpath at build time. Defaults to true.

Examples

Add a new dependency

ALTER MODULE MyModule
  ADD JAR DEPENDENCY (
    group    = 'org.duckdb',
    artifact = 'duckdb_jdbc',
    version  = '1.1.3',
    included = true,
  );

Update the version

ALTER MODULE MyModule
  SET JAR DEPENDENCY 'org.duckdb:duckdb_jdbc' VERSION '1.2.0';

Disable a dependency without removing it

ALTER MODULE MyModule
  SET JAR DEPENDENCY 'org.duckdb:duckdb_jdbc' INCLUDED false;

Add a transitive exclusion

ALTER MODULE MyModule
  SET JAR DEPENDENCY 'org.duckdb:duckdb_jdbc'
    ADD EXCLUSION 'com.example:conflicting-lib';

Remove a transitive exclusion

ALTER MODULE MyModule
  SET JAR DEPENDENCY 'org.duckdb:duckdb_jdbc'
    DROP EXCLUSION 'com.example:conflicting-lib';

Remove a dependency entirely

ALTER MODULE MyModule
  DROP JAR DEPENDENCY 'org.duckdb:duckdb_jdbc';

Chain multiple actions

ALTER MODULE MyModule
  ADD JAR DEPENDENCY (
    group    = 'com.fasterxml.jackson.core',
    artifact = 'jackson-databind',
    version  = '2.21.2',
    included = true,
  )
  ADD JAR DEPENDENCY (
    group    = 'com.fasterxml.jackson.core',
    artifact = 'jackson-core',
    version  = '2.21.2',
    included = true,
  );

Notes

Writing the dependency updates the .mpr file immediately, but Gradle must still resolve and download the JAR into vendorlib/. This happens automatically when the project is opened in Studio Pro or built with mxcli docker build.

See Also

LIST / DESCRIBE JAR DEPENDENCY

LIST / DESCRIBE JAR DEPENDENCY

Synopsis

LIST JAR DEPENDENCIES;
LIST JAR DEPENDENCIES IN module;
DESCRIBE JAR DEPENDENCY module 'group:artifact';

Description

LIST JAR DEPENDENCIES shows all Maven/JAR dependencies declared across the project, optionally filtered to a single module. DESCRIBE JAR DEPENDENCY outputs the full definition of a single dependency as a re-executable ALTER MODULE … ADD JAR DEPENDENCY statement.

Parameters

IN module
Filters the listing to a single module.
module
The module name containing the dependency (for DESCRIBE).
‘group:artifact’
The Maven coordinate identifying the dependency, in groupId:artifactId format (no version). Example: 'org.duckdb:duckdb_jdbc'.

Examples

List all JAR dependencies

LIST JAR DEPENDENCIES;

Output:

module      group                         artifact           version   included
MyModule    com.fasterxml.jackson.core    jackson-databind   2.21.2    true
MyModule    org.duckdb                    duckdb_jdbc        1.1.3     true

Filter by module

LIST JAR DEPENDENCIES IN MyModule;

Describe a specific dependency

DESCRIBE JAR DEPENDENCY MyModule 'com.fasterxml.jackson.core:jackson-databind';

Output:

alter module MyModule
  add jar dependency (
    group    = 'com.fasterxml.jackson.core',
    artifact = 'jackson-databind',
    version  = '2.21.2',
    included = true,
  );

The output is suitable for copying into a script and re-executing.

See Also

ALTER MODULE JAR DEPENDENCY

Session Statements

Statements for inspecting and configuring the current REPL session.

StatementDescription
SETSet a session variable
SHOW STATUSDisplay the current connection and session information

The session’s language

The REPL and mxcli -c "…" read input without a language header as mdl 1. mxcli --mdl 0 (or mxcli -c "…" --mdl 0) starts them in mdl 0, and in the REPL an mdl 0; or mdl 1; statement switches the session for what follows; describe in the session writes the session’s language. Session statements are accepted here under every version, and the last statement of an input needs no ;. A script file is read by its own header; see Language Versions and Migration.

SET

Synopsis

SET variable = value

Description

Sets a session variable that controls REPL behavior. Session variables persist for the duration of the session and are reset when the REPL exits. Variable names are case-insensitive.

Parameters

variable
The name of the session variable to set.
value
The value to assign. Can be a string (in single quotes), a boolean (TRUE or FALSE), or an unquoted identifier.

Recognized Variables

VariableValuesDescription
output_format / FORMAT'json', 'table'Controls output format for query results
verboseTRUE, FALSEEnable verbose output for debugging
AUTOCOMMITTRUE, FALSEAutomatically save changes after each mutation

Examples

Set output format to JSON

SET output_format = 'json';

Enable verbose mode

SET verbose = TRUE;

Enable autocommit

SET AUTOCOMMIT = TRUE;

See Also

SHOW STATUS, OPEN PROJECT

SHOW STATUS

Synopsis

STATUS

Description

Displays the current session status including the connection state, the open project path, the Mendix version of the project, the MPR format version, and the values of any session variables. This is useful for confirming which project is loaded and verifying session configuration.

Parameters

This statement takes no parameters.

Examples

Check session status

STATUS;

Sample output:

Status: Connected
Project: /Users/dev/projects/MyApp/MyApp.mpr
Version: 10.6.0
Format:  v2
Modules: 5

Verify connection before running commands

STATUS;
LIST MODULES;

See Also

OPEN PROJECT, CLOSE PROJECT, SET

System Architecture

ModelSDK Go is a Go-native library for reading and modifying Mendix application projects (.mpr files). It provides programmatic access to Mendix projects without cloud connectivity, serving as an alternative to the TypeScript-based Mendix Model SDK.

High-Level Architecture

The system is organized into layered components, from user-facing interfaces down to file system storage:

graph TB
    subgraph "User Interface"
        CLI[mxcli CLI]
        API[Go API]
        VSCODE[VS Code Extension]
    end

    subgraph "MDL Layer"
        REPL[REPL Interface]
        LSP[LSP Server]
        EXEC[Executor]
        PARSER[ANTLR4 Parser]
        AST[AST Nodes]
        VISITOR[AST Visitor]
        CATALOG[Catalog]
        LINTER[Linter]
    end

    subgraph "High-Level API"
        HLAPI[api/]
        DMAPI[DomainModelsAPI]
        MFAPI[MicroflowsAPI]
        PGAPI[PagesAPI]
        ENUMAPI[EnumerationsAPI]
        MODAPI[ModulesAPI]
    end

    subgraph "SDK Layer"
        SDK[modelsdk.go]
        DM[domainmodel]
        MF[microflows]
        PG[pages]
        MODEL[model]
        WIDGETS[widgets]
    end

    subgraph "External SQL"
        SQLPKG[sql/]
        SQLCONN[Connection Manager]
        SQLQUERY[Query Engine]
        SQLIMPORT[Import Pipeline]
        SQLGEN[Connector Generator]
    end

    subgraph "Storage Layer"
        READER[MPR Reader]
        WRITER[MPR Writer]
        BSONP[BSON Parser]
    end

    subgraph "File System"
        MPR[(MPR File)]
        CONTENTS[(mprcontents/)]
        EXTDB[(External DBs)]
    end

    CLI --> REPL
    CLI --> LSP
    API --> HLAPI
    API --> SDK
    VSCODE --> LSP
    HLAPI --> SDK
    HLAPI --> READER
    HLAPI --> WRITER

    REPL --> EXEC
    LSP --> EXEC
    EXEC --> PARSER
    PARSER --> AST
    AST --> VISITOR
    VISITOR --> AST
    EXEC --> SDK
    EXEC --> SQLPKG
    EXEC --> CATALOG
    EXEC --> LINTER

    SDK --> DM
    SDK --> MF
    SDK --> PG
    SDK --> MODEL
    SDK --> WIDGETS
    SDK --> READER
    SDK --> WRITER

    SQLPKG --> SQLCONN
    SQLPKG --> SQLQUERY
    SQLPKG --> SQLIMPORT
    SQLPKG --> SQLGEN
    SQLCONN --> EXTDB

    READER --> BSONP
    WRITER --> BSONP
    READER --> MPR
    READER --> CONTENTS
    WRITER --> MPR
    WRITER --> CONTENTS

Layer Summary

LayerPurpose
User InterfaceCLI (mxcli), Go API, and VS Code extension provide entry points for users and tools
MDL LayerSQL-like language parser, executor, REPL, LSP server, catalog, and linter
High-Level APIFluent builder API for domain models, microflows, pages, enumerations, and modules
SDK LayerCore Go types and operations for Mendix model elements
External SQLDatabase connectivity for PostgreSQL, Oracle, and SQL Server
Storage LayerMPR file reading, writing, and BSON parsing
File SystemPhysical .mpr files, mprcontents/ directories, and external databases

Key Design Decisions

  1. ANTLR4 for Grammar – Enables cross-language grammar sharing (Go, TypeScript, Java) with case-insensitive keywords and built-in error recovery.

  2. BSON for Serialization – Native Mendix format compatibility using go.mongodb.org/mongo-driver/bson, handling polymorphic types via $Type fields.

  3. Two-Phase Loading – Units are loaded on-demand for performance, with lazy loading of related documents.

  4. Interface-Based Design – Element and AttributeType interfaces enable type-safe polymorphic operations across element types.

  5. Pure Go / No CGO – Uses modernc.org/sqlite (pure Go SQLite) to eliminate the C compiler dependency, simplifying cross-compilation and deployment.

  6. Widget Template System – Pluggable widgets require complex BSON with internal ID references. Embedded JSON templates extracted from Studio Pro are cloned and customized at runtime.

  7. Credential Isolation – External database credentials resolve from environment variables or YAML config, never stored in MDL scripts.

Layer Diagram

Detailed description of each architectural layer in ModelSDK Go.

1. Command Layer (cmd/)

PackagePurpose
cmd/mxcliCLI entry point using Cobra framework; includes LSP server, Docker integration, diagnostics
cmd/codegenMetamodel code generator from reflection data

Key CLI subcommands:

SubcommandFilePurpose
execcmd_exec.goExecute MDL script files
checkcmd_check.goSyntax and reference validation
lintcmd_lint.goRun linting rules
reportcmd_report.goBest practices report
testcmd_test_run.goRun .test.mdl / .test.md tests
diffcmd_diff.goCompare script against project
sqlcmd_sql.goExternal SQL queries
lsplsp.goLanguage Server Protocol server
initinit.goProject initialization
dockerdocker.goDocker build/check/OQL integration
diagdiag.goSession logs, bug report bundles

2. MDL Layer (mdl/)

The MDL (Mendix Definition Language) layer provides a SQL-like interface for querying and modifying Mendix models.

sequenceDiagram
    participant User
    participant REPL
    participant Parser
    participant Visitor
    participant Executor
    participant SDK

    User->>REPL: "LIST ENTITIES IN MyModule"
    REPL->>Parser: Parse MDL command
    Parser->>Visitor: Walk parse tree
    Visitor->>Executor: Build AST node
    Executor->>SDK: ListDomainModels()
    SDK-->>Executor: []*DomainModel
    Executor-->>REPL: Formatted output
    REPL-->>User: Display table
PackagePurpose
mdl/grammarANTLR4 lexer/parser (generated from MDLLexer.g4 + MDLParser.g4)
mdl/astAST node types for MDL statements
mdl/visitorANTLR listener that builds AST from parse tree
mdl/executorExecutes AST nodes against the SDK (~45k lines across 40+ files)
mdl/catalogSQLite-based catalog for querying project metadata
mdl/linterExtensible linting framework with built-in rules and Starlark scripting
mdl/replInteractive REPL interface

3. High-Level API Layer (api/)

The api/ package provides a simplified, fluent builder API inspired by the Mendix Web Extensibility Model API.

classDiagram
    class ModelAPI {
        +writer *Writer
        +reader *Reader
        +currentModule *Module
        +DomainModels *DomainModelsAPI
        +Microflows *MicroflowsAPI
        +Pages *PagesAPI
        +Enumerations *EnumerationsAPI
        +Modules *ModulesAPI
        +New(writer) *ModelAPI
        +SetModule(module) *ModelAPI
    }

    class EntityBuilder {
        +Persistent() *EntityBuilder
        +NonPersistent() *EntityBuilder
        +WithStringAttribute(name, length) *EntityBuilder
        +WithIntegerAttribute(name) *EntityBuilder
        +Build() (*Entity, error)
    }

    class MicroflowBuilder {
        +WithParameter(name, entity) *MicroflowBuilder
        +WithStringParameter(name) *MicroflowBuilder
        +ReturnsBoolean() *MicroflowBuilder
        +Build() (*Microflow, error)
    }

    ModelAPI --> EntityBuilder : creates
    ModelAPI --> MicroflowBuilder : creates
FilePurpose
api/api.goModelAPI entry point with namespace access
api/domainmodels.goEntityBuilder, AssociationBuilder, AttributeBuilder
api/enumerations.goEnumerationBuilder, EnumValueBuilder
api/microflows.goMicroflowBuilder with parameters and return types
api/pages.goPageBuilder, widget builders
api/modules.goModulesAPI for module retrieval

4. SDK Layer (sdk/)

The SDK layer provides Go types and APIs for Mendix model elements.

classDiagram
    class Reader {
        +Open(path) Reader
        +ListModules() []*Module
        +ListDomainModels() []*DomainModel
        +ListMicroflows() []*Microflow
        +ListPages() []*Page
        +FindCustomWidgetType(widgetID) *RawCustomWidgetType
        +Close()
    }

    class Writer {
        +OpenForWriting(path) Writer
        +AddEntity(dm, entity)
        +AddAssociation(dm, assoc)
        +Save()
        +Close()
    }

    class DomainModel {
        +ID
        +ContainerID
        +Entities []*Entity
        +Associations []*Association
    }

    class Entity {
        +ID
        +Name
        +Attributes []*Attribute
        +Source string
        +OqlQuery string
    }

    Reader --> DomainModel
    Writer --> DomainModel
    DomainModel --> Entity
PackagePurpose
modelsdk/The MPR engine: file format, BSON codec, canonical form
mdl/backend/modelsdk/Backend implementation — reads and writes documents through the codec
sdk/domainmodelEntity, Attribute, Association types
sdk/microflowsMicroflow, Activity types (60+ types)
sdk/pagesPage, Widget types (50+ types)
sdk/widgetsEmbedded widget templates for pluggable widgets

The engine is split across modelsdk/, with the per-document-type mapping in mdl/backend/modelsdk/:

Package / patternPurpose
modelsdk/mpr/MPR file access: reader, writer, raw units, the write choke point
modelsdk/codec/Document ↔ BSON (encoder.go, decoder.go, type defaults, list markers)
modelsdk/canon/Canonical form, identity transplant, write elision (ADR-0008)
modelsdk/gen/Vendored metamodel types
mdl/backend/modelsdk/*_write.goSemantic model → gen → BSON, per document type
mdl/backend/modelsdk/*_read.goBSON → gen → semantic model

5. Model Layer (model/)

Core types shared across the SDK:

classDiagram
    class ID {
        <<type alias>>
        string
    }

    class BaseElement {
        +ID ID
        +TypeName string
    }

    class QualifiedName {
        +Module string
        +Name string
    }

    class Module {
        +BaseElement
        +Name string
        +FromAppStore bool
    }

    class Text {
        +Translations map
        +GetTranslation(lang) string
    }

    BaseElement <|-- Module

6. External SQL Layer (sql/)

The sql/ package provides external database connectivity for querying PostgreSQL, Oracle, and SQL Server databases.

flowchart LR
    subgraph "MDL Commands"
        CONNECT["SQL CONNECT"]
        QUERY["SQL alias SELECT ..."]
        IMPORT["IMPORT FROM alias ..."]
        GENERATE["SQL alias GENERATE CONNECTOR"]
    end

    subgraph "sql/ Package"
        CONN[Connection Manager]
        QE[Query Engine]
        IMP[Import Pipeline]
        GEN[Connector Generator]
        META[Schema Introspection]
        FMT[Output Formatters]
    end

    subgraph "Databases"
        PG[(PostgreSQL)]
        ORA[(Oracle)]
        MSSQL[(SQL Server)]
    end

    CONNECT --> CONN
    QUERY --> QE
    IMPORT --> IMP
    GENERATE --> GEN

    CONN --> PG
    CONN --> ORA
    CONN --> MSSQL
    QE --> CONN
    IMP --> CONN
    GEN --> META
    META --> CONN
FilePurpose
driver.goDriverName type, ParseDriver()
connection.goManager, Connection, credential isolation
config.goDSN resolution (env vars, YAML config)
query.goExecute() – query via database/sql
meta.goShowTables(), DescribeTable() via information_schema
import.goIMPORT pipeline: batch insert, ID generation, sequence tracking
generate.goDatabase Connector MDL generation from external schema
typemap.goSQL to Mendix type mapping, DSN to JDBC URL conversion
mendix.goMendix DB DSN builder, table/column name helpers
format.goTable and JSON output formatters

7. VS Code Extension (vscode-mdl/)

The VS Code extension provides MDL language support via an LSP client that communicates with mxcli lsp --stdio.

graph TB
    subgraph "VS Code"
        EXT[extension.ts]
        TREE[Project Tree Provider]
        LINK[Terminal Link Provider]
        CONTENT[MDL Content Provider]
        PREVIEW[Preview Provider]
    end

    subgraph "Preview Renderers"
        DMRENDER[Domain Model]
        MFRENDER[Microflow]
        PGRENDER[Page Wireframe]
        MODRENDER[Module Overview]
        QPRENDER[Query Plan]
    end

    subgraph "mxcli"
        LSP[LSP Server]
    end

    EXT --> TREE
    EXT --> LINK
    EXT --> CONTENT
    EXT --> PREVIEW
    PREVIEW --> DMRENDER
    PREVIEW --> MFRENDER
    PREVIEW --> PGRENDER
    PREVIEW --> MODRENDER
    PREVIEW --> QPRENDER
    EXT -->|stdio| LSP

LSP features include syntax highlighting, parse/semantic diagnostics, completion, symbols, folding, hover, go-to-definition, clickable terminal links, and context menu commands.

8. LSP Server (cmd/mxcli/lsp*.go)

The LSP server is embedded in the mxcli binary:

FilePurpose
lsp.goMain LSP server, hover, go-to-definition
lsp_diagnostics.goParse and semantic error reporting
lsp_completion.goContext-aware completions
lsp_completions_gen.goGenerated completion data
lsp_symbols.goDocument symbols
lsp_folding.goCode folding ranges
lsp_hover.goHover information
lsp_helpers.goShared utilities

Package Structure

Overview of the Go package organization and dependency relationships.

Package Dependency Graph

graph LR
    subgraph "cmd/"
        MXCLI[mxcli/]
        CODEGEN[codegen/]
    end

    subgraph "mdl/"
        GRAMMAR[grammar/]
        ASTPKG[ast/]
        VISITORPKG[visitor/]
        EXECUTORPKG[executor/]
        CATALOGPKG[catalog/]
        LINTERPKG[linter/]
        REPLPKG[repl/]
    end

    subgraph "api/"
        APIPKG[api.go]
        DMAPIMOD[domainmodels.go]
        MFAPIMOD[microflows.go]
        PGAPIMOD[pages.go]
        ENUMAPIMOD[enumerations.go]
        MODAPIMOD[modules.go]
    end

    subgraph "sdk/"
        MPR[mpr/]
        DMPKG[domainmodel/]
        MFPKG[microflows/]
        PGPKG[pages/]
        WIDGETSPKG[widgets/]
    end

    subgraph "sql/"
        SQLCONN[connection.go]
        SQLQUERY[query.go]
        SQLIMPORT[import.go]
        SQLGEN[generate.go]
        SQLMETA[meta.go]
    end

    subgraph "internal/"
        CODEGENINT[codegen/]
    end

    subgraph "vscode-mdl/"
        VSEXT[extension.ts]
        VSLSP[LSP client]
        VSPREVIEW[preview/]
        VSTREE[projectTreeProvider.ts]
    end

    subgraph "Root"
        MODELSDK[modelsdk.go]
        MODELPKG[model/]
    end

    MXCLI --> REPLPKG
    MXCLI --> EXECUTORPKG
    REPLPKG --> EXECUTORPKG
    EXECUTORPKG --> VISITORPKG
    VISITORPKG --> GRAMMAR
    EXECUTORPKG --> MODELSDK
    EXECUTORPKG --> CATALOGPKG
    EXECUTORPKG --> LINTERPKG
    APIPKG --> MPR
    APIPKG --> DMPKG
    APIPKG --> MFPKG
    APIPKG --> PGPKG
    MODELSDK --> MPR
    MODELSDK --> DMPKG
    MODELSDK --> MFPKG
    MODELSDK --> PGPKG
    MPR --> MODELPKG
    VSEXT --> VSLSP

Directory Layout

github.com/mendixlabs/mxcli/
├── modelsdk.go              # Main public API (Open, OpenForWriting, helpers)
├── model/                   # Core types: ID, QualifiedName, Module, Element interface
│
├── api/                     # High-level fluent API
│   ├── api.go               # ModelAPI entry point with namespace access
│   ├── domainmodels.go      # EntityBuilder, AssociationBuilder, AttributeBuilder
│   ├── enumerations.go      # EnumerationBuilder
│   ├── microflows.go        # MicroflowBuilder
│   ├── pages.go             # PageBuilder, widget builders
│   └── modules.go           # ModulesAPI
│
├── sdk/                     # SDK implementation packages
│   ├── domainmodel/         # Entity, Attribute, Association, DomainModel
│   ├── microflows/          # Microflow, Nanoflow, activities (60+ types)
│   ├── pages/               # Page, Layout, Widget types (50+ widgets)
│   ├── widgets/             # Embedded widget templates for pluggable widgets
│   │   ├── loader.go        # Template loading with go:embed
│   │   └── templates/       # JSON widget type definitions by Mendix version
│   └── mpr/                 # MPR file format handling
│       ├── reader.go        # Read-only MPR access
│       ├── writer.go        # Read-write MPR modification
│       ├── parser.go        # BSON parsing and deserialization
│       └── utils.go         # UUID generation utilities
│
├── mdl/                     # MDL parser & CLI
│   ├── grammar/             # ANTLR4 grammar definition
│   │   ├── MDLLexer.g4      # Lexer grammar (tokens)
│   │   ├── MDLParser.g4     # Parser grammar (rules)
│   │   └── parser/          # Generated Go parser code
│   ├── ast/                 # AST node types for MDL statements
│   ├── visitor/             # ANTLR listener to build AST
│   ├── executor/            # Executes AST against modelsdk-go
│   ├── catalog/             # SQLite-based catalog for querying project metadata
│   ├── linter/              # Extensible linting framework
│   │   └── rules/           # Built-in lint rules
│   └── repl/                # Interactive REPL interface
│
├── sql/                     # External database connectivity
│   ├── driver.go            # DriverName type, ParseDriver()
│   ├── connection.go        # Manager, Connection, credential isolation
│   ├── config.go            # DSN resolution (env vars, YAML config)
│   ├── query.go             # Execute() -- query via database/sql
│   ├── meta.go              # ShowTables(), DescribeTable()
│   ├── format.go            # Table and JSON output formatters
│   ├── mendix.go            # Mendix DB DSN builder, table/column name helpers
│   ├── import.go            # IMPORT pipeline: batch insert, ID generation
│   ├── generate.go          # Database Connector MDL generation
│   └── typemap.go           # SQL to Mendix type mapping
│
├── cmd/                     # Command-line tools
│   ├── mxcli/               # CLI entry point (Cobra-based)
│   └── codegen/             # Code generator CLI
│
├── internal/                # Internal packages (not exported)
│   └── codegen/             # Metamodel code generation system
│       ├── schema/          # JSON reflection data loading
│       ├── transform/       # Transform to Go types
│       └── emit/            # Go source code generation
│
├── generated/metamodel/     # Auto-generated type definitions
├── examples/                # Usage examples
│
├── vscode-mdl/              # VS Code extension (TypeScript, uses bun)
│   ├── src/extension.ts     # Extension entry point
│   └── package.json         # Extension manifest
│
└── reference/               # Reference materials (not Go code)
    ├── mendixmodelsdk/      # TypeScript SDK reference
    └── mdl-grammar/         # Comprehensive MDL grammar reference

Key Package Responsibilities

PackageExportedDescription
modelsdk (root)YesMain public API: Open(), OpenForWriting(), helper constructors
model/YesCore types: ID, QualifiedName, Module, Element interface
api/YesFluent builder API for domain models, microflows, pages
sdk/domainmodel/YesEntity, Attribute, Association Go types
sdk/microflows/YesMicroflow, Nanoflow, 60+ activity types
sdk/pages/YesPage, Layout, 50+ widget types
sdk/widgets/YesWidget template loading and cloning
modelsdk/mpr/YesMPR file format: reader, writer, raw unit access
modelsdk/codec/YesDocument ↔ BSON encode/decode
modelsdk/canon/YesCanonical form, identity transplant, write elision
modelsdk/gen/YesVendored metamodel types
mdl/backend/modelsdk/NoBackend implementation: semantic model ↔ gen/BSON
mdl/grammar/YesANTLR4 generated lexer/parser
mdl/ast/YesAST node types for all MDL statements
mdl/visitor/YesParse tree to AST conversion
mdl/executor/YesAST execution against SDK
mdl/catalog/YesSQLite metadata catalog
mdl/linter/YesLinting framework and rules
sql/YesExternal database connectivity
internal/codegen/NoMetamodel code generation (not exported)

Dependencies

DependencyPurpose
modernc.org/sqlitePure Go SQLite driver (no CGO required)
go.mongodb.org/mongo-driverBSON parsing for Mendix document format
github.com/antlr4-go/antlr/v4ANTLR4 Go runtime for MDL parser
github.com/spf13/cobraCLI framework
github.com/jackc/pgx/v5PostgreSQL driver
github.com/sijms/go-ora/v2Oracle driver
github.com/microsoft/go-mssqldbSQL Server driver

Design Decisions

Key architectural decisions made during the development of ModelSDK Go, with rationale and trade-offs.

Pure Go SQLite (No CGO)

Decision: Use modernc.org/sqlite instead of mattn/go-sqlite3.

Rationale: mattn/go-sqlite3 requires CGO and a C compiler, complicating cross-compilation and deployment. The pure Go implementation works on all platforms without external dependencies.

Trade-off: Slightly lower SQLite performance compared to the C implementation. In practice, MPR files are small enough that this is not measurable.

BSON with MongoDB Driver

Decision: Use go.mongodb.org/mongo-driver for BSON parsing.

Rationale: Mendix stores model elements as BSON documents. The MongoDB Go driver provides a mature, well-tested BSON implementation. No actual MongoDB connection is needed – only the BSON codec is used.

SQL-Like DSL (MDL)

Decision: Create a custom SQL-like language instead of using JSON, YAML, or a general-purpose language.

Rationale:

  • SQL is familiar to most developers
  • LLMs generate SQL-like syntax with high accuracy
  • 5-10x more token-efficient than JSON representations
  • Human-readable diffs are easier to review than binary or JSON changes
  • High information density for scanning microflow logic

Trade-off: Requires building and maintaining a custom parser (ANTLR4).

ANTLR4 for Parsing

Decision: Use ANTLR4 instead of hand-written parser or Go-native parser generators.

Rationale:

  • Grammar is shareable across languages (Go, TypeScript, Java, Python)
  • Well-defined grammar file serves as the language specification
  • Rich tooling for grammar debugging and visualization
  • Generated parser handles error recovery automatically

Trade-off: ANTLR4 Go target generates large files. Regeneration requires the ANTLR4 tool (make grammar).

Storage Names in BSON

Decision: Always use storageName (not qualifiedName) for the $Type field in BSON.

Rationale: Studio Pro uses storage names internally. Using qualified names causes TypeCacheUnknownTypeException. The two names are often identical, but critical exceptions exist (e.g., DomainModels$Entity vs DomainModels$EntityImpl).

Inverted Association Pointer Names

Decision: Preserve Mendix’s counter-intuitive naming where ParentPointer points to the FROM entity and ChildPointer points to the TO entity.

Rationale: Changing the field names would create a mapping layer that hides the actual BSON structure, making debugging harder. By keeping the original names, developers can directly compare Go structs with raw BSON output.

Trade-off: Every developer working with associations must learn the inverted semantics. Documentation and comments are essential.

Embedded Widget Templates

Decision: Embed a single conservative baseline template per widget (from the oldest supported Mendix version) and use runtime augmentation from the project’s .mpk files to adapt to version differences.

Rationale: Pluggable widgets require exact property schemas that change between Mendix versions. The Gallery widget, for example, has 23 properties in Mendix 10.24 but 33 in 11.6.3. Rather than shipping multiple templates per version, a single baseline works because:

  1. Augmentation adds missing properties: If the project’s .mpk declares properties not in the template, they are cloned from type-matching exemplars or created from scratch.
  2. Augmentation removes stale properties: Properties in the template but absent from the .mpk are stripped, preventing CE0463.
  3. The baseline must be the oldest supported version: A newer baseline may have structural changes that augmentation cannot reverse (different property types, different nesting). An older baseline is safe because augmentation only needs to add/remove properties, not restructure them.

Trade-off: The augmentation system handles property-level drift (added/removed properties) but cannot fix structural changes (renamed types, changed nesting depth). If a widget undergoes a major schema overhaul between versions, a version-specific template may be needed.

See Widget Template System and Pluggable Widget Engine for implementation details.

Catalog as SQLite

Decision: Use an in-memory SQLite database for the project metadata catalog.

Rationale: SQLite provides indexing, FTS5 full-text search, and standard SQL querying without any external dependency. The catalog is rebuilt from the MPR on each session, so persistence is not needed.

Fluent API Layer

Decision: Provide both a low-level SDK (direct struct construction) and a high-level fluent API (api/ package).

Rationale: The low-level SDK gives full control over BSON structure, which is needed for complex operations. The fluent API reduces boilerplate for common tasks and makes test code more readable.

Trade-off: Two API surfaces to maintain and document.

MPR File Format

Mendix projects are stored in .mpr files, which are SQLite databases containing BSON-encoded model elements. This page provides an overview of the format; see v1 vs v2 for version-specific details.

Structure

An MPR file is a standard SQLite database. Both format versions carry the same two tables; v2 adds a third. Contents live in the Unit table in v1 and in mprcontents/ in v2 — there is no separate contents table in either format.

Tablev1v2Holds
UnityesyesOne row per document
_MetaDatayesyesMendix product/build version and schema hash
_TransactionnoyesLastTransactionID, bumped on every unit write

Unit Table

The Unit table has one row per document. Its columns are identical in both formats except for Contents, which only v1 has:

Columnv1v2Description
UnitIDyesyesBinary UUID identifying the document (.NET GUID byte order — see below)
ContainerIDyesyesParent unit’s UnitID; the project root is its own container
ContainmentNameyesyesRelationship name (e.g. ProjectDocuments), empty on the root
TreeConflictyesyesVersion-control conflict marker
ContentsHashyesyesBase64 SHA-256 of the document BSON
ContentsConflictsyesyesVersion-control conflict marker for contents
ContentsyesnoBSON blob containing the full document

The row carries no unit-type and no name column — the seven above are all there is. A document’s type and name are read out of its BSON $Type and Name fields, which is why listing units by type requires decoding contents (getTypeFromContents in modelsdk/mpr/reader_units.go).

Where Contents Live

In v1, document BSON is the Unit.Contents blob:

SELECT Contents FROM Unit WHERE UnitID = ?;          -- read
UPDATE Unit SET Contents = ? WHERE UnitID = ?;       -- write

In v2, Unit.Contents does not exist. Each document is a file under mprcontents/, sharded two levels deep by the first four hex characters of its UUID:

mprcontents/<XX>/<YY>/<UUID>.mxunit

The UUID in the path is the UnitID blob rendered in .NET GUID byte order — the first three fields are little-endian, so blob FADF10BF FF61 8842 8A63D53AE4522615 becomes bf10dffa-61ff-4288-8a63-d53ae4522615. Writing a v2 unit updates Unit.ContentsHash (and _Transaction.LastTransactionID) in SQLite after the file lands.

Unit Types

Every document has a type, carried in its BSON $Type field. It is not a column on Unit, so identifying a document means decoding its contents.

These are storage names, and for the page family they differ from the names the TypeScript SDK uses: a page is stored as Forms$Page, never Pages$Page – “Form” was the original term for “Page”. Using the SDK spelling to select documents matches nothing, which is a wrong answer rather than an error. See Storage Names.

The set below is measured: it is every distinct $Type in a blank Mendix 11.6.6 app (369 units) unioned with a 9.24.30 app (20 units).

$TypeDocument Type
Constants$ConstantConstant
CustomIcons$CustomIconCollectionCustom icon collection
DomainModels$DomainModelDomain model (entities, associations)
Enumerations$EnumerationEnumeration
ExportMappings$ExportMappingExport mapping
Forms$BuildingBlockBuilding block
Forms$LayoutLayout
Forms$PagePage
Forms$PageTemplatePage template
Forms$SnippetSnippet
Images$ImageCollectionImage collection
ImportMappings$ImportMappingImport mapping
JavaActions$JavaActionJava action
JavaScriptActions$JavaScriptActionJavaScript action
JsonStructures$JsonStructureJSON structure
Menus$MenuDocumentMenu document
Microflows$MicroflowMicroflow
Microflows$NanoflowNanoflow
Navigation$NavigationDocumentNavigation profile
Projects$FolderFolder
Projects$ModuleImplModule
Projects$ModuleSettingsPer-module settings
Projects$ProjectProject root
Projects$ProjectConversionVersion-conversion record
Security$ModuleSecurityModule security settings
Security$ProjectSecurityProject security settings
Settings$ProjectSettingsProject settings
Texts$SystemTextCollectionSystem text collection

A project with no instance of a document type simply has no unit of it, so these are named from mxcli’s own readers and writers rather than measured above:

$TypeDocument Type
BusinessEvents$BusinessEventServiceBusiness event service
CustomBlobDocuments$CustomBlobDocumentCustom blob document
DomainModels$ViewEntitySourceDocumentOQL query for VIEW entities
Microflows$RuleRule (a rule is a flow, so it is in the Microflows namespace)
Queues$QueueTask queue
RegularExpressions$RegularExpressionRegular expression
ScheduledEvents$ScheduledEventScheduled event

BSON Document Structure

Every BSON document contains at minimum:

FieldTypeDescription
$IDBinary (UUID)Unique identifier for this element
$TypeStringFully qualified type name (using storageName, not qualifiedName)

ID Format

IDs are stored as BSON Binary subtype 0 (generic) containing UUID bytes:

{
  "$ID": {
    "Subtype": 0,
    "Data": "base64-encoded-uuid"
  }
}

Array Convention

Arrays in Mendix BSON have an integer count as the first element:

{
  "Attributes": [
    3,              // Count/type prefix
    { ... },        // First attribute
    { ... }         // Second attribute
  ]
}

The prefix value (typically 2 or 3) indicates the array type. When writing arrays, you must include this prefix. When parsing, skip the first element.

Reference Types

Reference TypeStorage FormatExample
BY_ID_REFERENCEBinary UUIDIndex AttributePointer
BY_NAME_REFERENCEQualified name stringValidationRule Attribute
PARTEmbedded BSON objectChild objects serialized inline

Using the wrong reference format causes Studio Pro to fail loading the model. Always check the metamodel reflection data to determine which format each property uses.

Storage Names vs Qualified Names

The $Type field in BSON must use the storageName, not the qualifiedName. These are often identical but not always:

qualifiedName (SDK)storageName (BSON $Type)
DomainModels$EntityDomainModels$EntityImpl
DomainModels$IndexDomainModels$EntityIndex

Using the wrong name causes TypeCacheUnknownTypeException when opening in Studio Pro. See Storage Names for more details.

Idempotent Writes

Re-running an MDL script against a project that is already in sync leaves the .mpr and mprcontents/ files byte-identical. git status stays clean and Studio Pro shows no version-control changes, because the write does not happen at all.

This matters beyond tidiness: without it, git diff cannot answer “did this script change anything”, two people running the same script commit different bytes, and a .mxunit merge conflict is not resolvable by hand.

Why a write used to happen anyway

create or replace does not reconcile. It rebuilds the document from the MDL and overwrites, and every sub-element in that rebuild is a new object with a freshly random $ID. The stored bytes were a function of the script and a random source, so no amount of care in the builders could have made them stable.

What mxcli compares

Before writing a unit, mxcli compares the new document against the stored one in a canonical form: every element $ID is replaced by its index in a deterministic walk, so a difference in which UUIDs were minted is not a difference. If the two are canonically equal, the write is skipped and the stored document — with its existing IDs — stays exactly as it was.

Skipping is safer than writing, not merely cheaper. The stored IDs are the ones every pointer inside that unit already agrees with, and nothing outside the unit can observe them: cross-document references are by qualified name.

The comparison is biased toward writing. If anything cannot be decided, mxcli writes. A redundant write costs you a diff; a wrongly skipped write would lose your edit.

Identity is preserved, not re-minted

A microflow carries a StableId, which Mendix declares as an identifier and which the build turns into the operation id the browser uses to call that microflow. mxcli carries the stored value onto the rebuilt document instead of minting a new one, so re-running a script does not renumber operations in your deployed model.

A changed document only shows what changed

Skipping the write answers “nothing changed”. When something did change, the document is still rebuilt from scratch — so without further care, a one-line edit lands as a wholesale replacement. Editing a single argument of a single JavaScript action call used to re-mint 36 of a nanoflow’s 37 element identities; the same edit to a microflow re-minted 21 of 22. Studio Pro’s changes view and git diff both key on those identities, so a two-line change read as “the whole document was replaced”. It was also cumulative: changing a value and changing it back produced a semantically identical document that shared none of its element IDs with the original.

mxcli now matches the rebuilt document against the stored one element by element — by shape, and by name where there is one — and puts the stored $ID back on every element that still corresponds, rewriting each pointer to it in the same pass. On the case above the diff is now the one changed line, all 37 identities kept, and a change plus its revert returns to the original bytes exactly.

Inserting or deleting an activity mints IDs only for the elements that are genuinely new; the rest of the flow keeps its identities rather than shifting onto its neighbours’.

Element IDs are not the database’s identity — a GUID is, and that has always been carried through. See the note in CLAUDE.md on why the two must not be confused.

Both engines, every write path

The policy lives in one place (modelsdk/canon) and is applied at the single write choke point of both the default modelsdk engine and the legacy engine. Which engine ran is an --engine flag, and it must not be visible in your diff.

Every element is written with Studio Pro’s property set

Before a unit is compared and written, every element in it gets every property Studio Pro always writes for its type, filled with the value Mendix itself uses when the property is absent. The reason is version control, not validity. Mendix’s merge and diff engine compares an element’s property names between two revisions, and throws when they differ:

System.InvalidOperationException: Objects with ID … of type
Microflows$LoopedActivity do not have the same properties.

A missing key is invisible everywhere else. mx check, the build and the runtime all fill in the default. So before this, a branch with documents mxcli had written could not be merged once Studio Pro had saved one of them (#1373).

The values are measured, not guessed. They live in modelsdk/canon/studiopro_property_defaults.json. Each one is taken from mx convert -p, which re-serializes exactly the units that are not canonical for its version, the way Studio Pro saves them. Each entry is bounded by the Mendix versions it was measured on. A key is never written to a project whose version may not declare it, because that would make the document unopenable, which is worse than the gap.

The other direction matters as much. A key the project’s version does not declare yet is removed before the write if it is empty, which is exactly what mx convert does. If it holds a value, the write is refused and the error names the version it needs. That version comes from the metamodel’s version data, copied into modelsdk/version by go generate, or, for keys that data lacks, from modelsdk/canon/studiopro_property_floors.json. Writers that derive a value for a newer key leave it out on older versions themselves, so the refusal only fires for something the statement asked for.

An element whose $Type the project’s version does not have yet is refused the same way, wherever it sits in the unit (canon.UndeclaredTypeError, ako/mxcli#1774). Each type’s introduction version is in modelsdk/canon/mendixmodelsdk_type_versions.json, extracted from the Mendix Model SDK by modelsdk/canon/gen_type_versions.go. The check runs after the property shapes are lowered for the version, so a type with an older shape is written in that shape instead of being refused. The SDK’s deletion versions are in the file too, but they are not enforced: a type the SDK renamed or moved keeps its storage name.

The doctype integration gate measures this on every script. A new gap fails it. To record a new measurement into the table, run the gate with MXCLI_PROPERTY_SETS_RECORD=<file> and then run go run ./internal/propertysets/cmd/propdefaults -record <file>.

A run of grants is compared as a whole

Most statements write the document they change, and that write is compared with what is on disk at that moment. A sequence of entity grant / revoke statements works differently. The run is held in memory and written once, when the next statement of another kind starts or the script ends, and the write is compared with what was stored before the run. This matters for a “reset, then grant” section:

mdl 1;
revoke all on entity Shop.Order from Shop.User;
grant read *, write * on entity Shop.Order to Shop.User;

Written one statement at a time, the grant would be compared with a domain model the revoke had already emptied. It would get a new access rule every run. Judged as one run, a reset that grants back what was there writes nothing, and a reset that changes a rule keeps the identity of every rule it grants back (#872). Statements inside the run read the run’s own writes.

Turning it off

MXCLI_ALWAYS_WRITE=1 mxcli exec script.mdl -p app.mpr

Every write lands, whether or not anything changed. This exists for bisecting a suspected elision bug (“does it still reproduce if nothing is skipped?”) and is not a supported option. It disables skipping only — identity is still preserved, because a forced write that re-minted StableId would change the deployed app rather than help you debug it.

Verifying it yourself

Run your script twice against a settled project and compare the stored units:

find mprcontents -name '*.mxunit' | sort | xargs sha256sum > before.txt
mxcli exec script.mdl -p app.mpr
find mprcontents -name '*.mxunit' | sort | xargs sha256sum > after.txt
diff before.txt after.txt        # expect no output

Two cautions, both of which produce a meaningless zero:

  • Make sure the script is actually re-runnable. A script containing create module or create enumeration fails on the second run and writes nothing, so the diff is empty for the wrong reason. Check the run’s output.

  • Run the control — and note that it is a control on mtimes, not on content. Since identities are carried, MXCLI_ALWAYS_WRITE=1 produces the same bytes; what it changes is that the files are rewritten at all. Compare stat -c %y instead of sha256sum and confirm the timestamps do move. If they do not, nothing ran and the clean result proves nothing.

    find mprcontents -name '*.mxunit' | sort | xargs stat -c '%Y %n' > t-before.txt
    MXCLI_ALWAYS_WRITE=1 mxcli exec script.mdl -p app.mpr
    find mprcontents -name '*.mxunit' | sort | xargs stat -c '%Y %n' > t-after.txt
    diff t-before.txt t-after.txt    # expect output — writes landed
    

The console tells you the same thing, per document: a statement whose write was skipped reports Unchanged nanoflow: … rather than Replaced nanoflow: ….

When a run skips several, they collapse into one line rather than one per statement:

Modified entity: MyFirstModule.Od07
Created entity: MyFirstModule.OdNew1
39 documents already in sync (unchanged, not listed)

Every write that actually landed is still named individually — only Unchanged is counted, because it is the one report that by construction says nothing happened. A run with exactly one elision prints it in full, so nothing is ever replaced by a count of one.

For a per-unit view of what would be skipped, scripts/mprsnapshot -canon emits canonical digests keyed by unit id.

See ADR-0008 for the decision and the measurements behind it.

Writes Are Conditional, and an $ID Is Never Renumbered In Place

Storage does not write a unit whose new content is semantically equal to what is stored (ADR-0008). The comparison is on a canonical form — every element $ID replaced by its index in a containment walk — because a rebuild mints a fresh random $ID per sub-element, so comparing bytes would skip nothing. The policy lives in modelsdk/canon (Reconcile) and is called at every write choke point in modelsdk/mpr/writer_core.go: updateUnit, WriteTransaction.WriteUnit (codec.Store reaches storage through this one) and — since ako/mxcli#556 — insertUnit, for the case below.

A delete followed by an insert is a write path too, and it is the one that hides. Several create or modify handlers are implemented as delete + create under the preserved unit ID rather than as an update, and an insert has nothing stored to reconcile against, so the rebuild’s fresh $IDs went straight to disk: create or modify consumed rest service rewrote 9 element $IDs in a 1,128-byte unit on every run, forever. deleteUnit now remembers what it removed and insertUnit reconciles a re-insert against it (carryIdentityFromRemovedUnit). That carry cannot elide — the row and the file are already gone — so a no-op recreate also restores _Transaction.LastTransactionID, which both the delete and the insert bumped; without it the .mpr still showed as modified after every .mxunit had gone quiet. Prefer an in-place update where the handler can do one: the REST client’s own fix is to call UpdateConsumedRestService and keep delete+create only for a folder move, which lives in the unit’s row rather than its contents.

The carry keys on the unit ID, so it does not reach a handler that re-mints one — and three handlers did, in one week: the REST client (#556), the view entity’s OQL document (#583) and the layout (ako/mxcli#600). All three took the same fix, an in-place UpdateRawUnit rather than a replacement. The tell is cheap and worth reaching for first: ls the .mxunit filenames across two identical runs. A changed filename is delete+insert and the handler is wrong; a same filename with different bytes is the codec or a missing carry and canon is where to look. A replacement also silently reverts the unit’s ROW, which is how create or replace layout moved a foldered layout back to the module root on every rewrite — there is no FOLDER clause on the statement, so the rebuild always names the module root and only an insert applies it.

When something has changed, Reconcile still does not let the rebuild’s fresh $IDs reach disk: canon.TransplantIDs matches the incoming document against the stored one element by element (by $Type and shape, by Name where there is one, LCS-anchored within each list) and puts the stored $ID back on every element that still corresponds. Without it a one-argument edit re-minted 36 of a nanoflow’s 37 element identities and Studio Pro painted the whole document as changed (#910). Its correctness bar is lower than it looks and worth knowing: a wrong match only makes a diff bigger, because every reference is rewritten with the element — the one real failure is two elements sharing an $ID, which dropCollisions guards.

Three rules follow, and each has already been violated once:

  1. Never rewrite an element $ID without rewriting every reference to it in the same pass. Pointers are primitive properties holding an element.ID, not ChildProperty, so a containment walk traverses the whole document and never sees one. PR #125 renumbered IDs this way and made projects unopenable (KeyNotFoundException at ResolvePostponedProperties). A unit is rewritten wholesale or not at all. The transplant obeys this by substituting over every 16-byte binary in the document rather than a maintained list of pointer properties — any occurrence of one of the document’s element IDs is a reference by definition, the same insight the canonical form rests on.
  2. Adding a write path means wiring it to canon.Reconcile. A new choke point that writes directly will silently churn while everything else is quiet — the worst kind of inconsistency, because the diff blames the wrong change.
  3. A new document type with an identity property needs a row in canon.identityFields. It cannot be generated: Mendix’s IsIdentifier lives in the modeler assemblies, not in the reflection data generated/metamodel is built from. TestFreshGUIDFieldsHaveAnIdentityDecision catches the common case (a property the codec mints fresh on every write) but cannot catch an identity property the codec does not mint. Establish the property’s status the way StableId was — the method table is in ADR-0008.

Elision itself is type-agnostic and covers new document types for free, but it assumes no binary pointer crosses a unit boundary (measured 0 of 9,910, not enforced). A document type that references another unit by $ID rather than by qualified name breaks that assumption and invalidates the argument in ADR-0008.

MXCLI_ALWAYS_WRITE=1 forces every write to land, for bisecting. It does not disable identity preservation. Any test asserting “nothing changed” must include a control — otherwise the test passes against a build that never had the fix, which is exactly how PR #125 shipped green. Note what the control can now be: since identities are carried, a forced write of an in-sync unit produces the same bytes, so “flip MXCLI_ALWAYS_WRITE and watch the content change” no longer distinguishes anything (measured: same sha, mtime moves). Control on the rebuild instead — encode the document twice and show the raw codec output differs (TestRebuildChurnsSubElementIDs) — or, from the shell, on mtimes rather than hashes.

The executor reports which of the two happened: a statement whose unit writes were all elided prints Unchanged nanoflow: … instead of Replaced nanoflow: … (ExecContext.ReportMutation, fed by each writer’s WriteStats). The verb is only downgraded on positive evidence — writes offered, none landed — so a mutation that never touches unit storage is reported exactly as before.

Several elisions in one run collapse into one line, because that report is the most repeated thing mxcli prints and an agent pays for it on every later model call (a tool result is written into the conversation once and re-read by each one). Measured on a settled 40-statement script: 41 lines / 1,604 B became 2 lines / 177 B. Two rules keep it honest, and each was arrived at by getting it wrong:

  1. Only Unchanged collapses. It is the one verb that by construction reports an absence, so no line a reader would act on is ever replaced by a number — a mixed run still names every real write individually and counts only the rest. Collapsing on volume instead (“after N lines”) would hide real writes in exactly the runs where they matter.
  2. The trigger is how many arrive, not which entry point ran. A lone elision is printed verbatim, since “1 document already in sync” is worse than the line it replaces. Gating on “is this a script?” looked equivalent and is not: -c reaches ExecuteProgram too, because executeMDL prepends a CONNECT statement, so a one-liner collapsed to a count of one.

mutationTally (mdl/executor/mutation_tally.go), active only inside a program run.

Every statement that writes is judged this way, not only the ones that report a document. Security and settings statements report a sentence (“Granted access on …”, “Set project security level to …”, “Updated configuration …”) and used to print it unconditionally, so a settled re-run announced writes that never reached disk (#890). They go through ExecContext.reportWrite, the same rule with a sentence for the write and Unchanged <subject> for the elision. A move to the container the document already has writes nothing and says Unchanged.

A run of access-rule statements is the one case where a statement cannot know at the time: its domain model is held until the run ends (#872). Its reports are held with it (accessRuleRun, mdl/executor/access_rule_run.go) and printed at the flush — as written, or as Unchanged when the flush was elided. A revoke + grant reset that nets to nothing therefore reports nothing written.

v1 (SQLite) vs v2 (mprcontents/)

Mendix uses two MPR file format versions. The library auto-detects and handles both.

MPR v1 (Mendix < 10.18)

A single .mpr SQLite database file containing all model data.

Storage

  • Unit table: one row per document — identity, containment, conflict markers, content hash, and the BSON blob itself in the Contents column
  • _MetaData table: Mendix product/build version and schema hash

There is no separate contents table. SELECT Contents FROM Unit WHERE UnitID = ? is the read; UPDATE Unit SET Contents = ? WHERE UnitID = ? is the write. See MPR File Format for the full column list.

Characteristics

  • Self-contained single file
  • Larger file size (entire project in one database)
  • Binary diffs make Git versioning difficult
  • All documents read/written through SQLite queries

Reading

// Auto-detected by modelsdk.Open()
reader, _ := modelsdk.Open("/path/to/project.mpr")
// reader.Version() returns 1

MPR v2 (Mendix >= 10.18)

An .mpr metadata file plus a mprcontents/ folder with individual document files.

Storage

  • .mpr file: SQLite with the same Unit table as v1 minus the Contents column, plus a _Transaction table holding LastTransactionID
  • mprcontents/ folder: individual .mxunit files containing BSON

Each .mxunit file holds the raw BSON for one document. It is named by the document’s UnitID rendered in .NET GUID byte order, and lives two directories deep — sharded by the first two and next two hex characters of that name.

Directory Layout

project/
├── app.mpr                    # SQLite metadata
└── mprcontents/
    ├── bf/
    │   └── 10/
    │       └── bf10dffa-61ff-4288-8a63-d53ae4522615.mxunit
    ├── 2a/
    │   └── 3b/
    │       └── 2a3b4c5d-....mxunit
    └── ...

A flat mprcontents/<UUID>.mxunit will not be found: the two shard directories are part of the path.

Characteristics

  • Better for Git versioning – individual files change independently
  • Smaller diffs when modifying a single document
  • Parallel read access to different documents
  • Requires both the .mpr file and the mprcontents/ folder

Reading

// Auto-detected by modelsdk.Open()
reader, _ := modelsdk.Open("/path/to/project.mpr")
// reader.Version() returns 2

Format Detection

Detection is by directory, with a schema check as the reconciliation step (modelsdk/mpr/reader.go):

StepCheckResult
1mprcontents/ exists next to the .mpr and is a directoryv2
2otherwisev1
3if step 2 said v1 but Unit has no Contents columnv2 after all

Step 3 exists because the folder check fails for a .mpr copied away from its mprcontents/. The schema is the ground truth there: Unit.Contents is present in v1 and absent in v2, which is the one column that distinguishes the two. Opening such a project for writing is refused rather than guessed at, since the SQLite rows would have no files behind them.

_MetaData._FormatVersion is a second, independent signal — present and equal to 2 in v2, absent in v1 — which mxcli does not currently use.

This detection is automatic – callers of Open() and OpenForWriting() do not need to specify the format.

Comparison

Featurev1v2
Mendix version< 10.18>= 10.18
File structureSingle .mpr.mpr + mprcontents/
TablesUnit, _MetaDataUnit, _MetaData, _Transaction
Document storageUnit.Contents blob in SQLiteIndividual .mxunit files
Git friendlinessPoor (binary diffs)Good (per-document files)
File sizeLarger single fileDistributed across files
Read performanceSingle DB queryFile I/O per document
Write granularityFull DB transactionPer-file write

Writing Behavior

When writing with OpenForWriting():

  • v1: UPDATE Unit SET Contents = ? WHERE UnitID = ? — the blob goes into the row it belongs to
  • v2: the BSON is written to a temp file and renamed into place at mprcontents/<XX>/<YY>/<UUID>.mxunit, then SQLite is updated with the new Unit.ContentsHash (base64 SHA-256 of the contents) and a fresh _Transaction.LastTransactionID

The writer handles both formats transparently.

BSON Document Structure

How Mendix project documents are serialized in BSON format, including the role of $Type fields, nested structures, array version markers, and widget serialization.

Source of Truth

The authoritative reference for BSON serialization is a document authored by Studio Pro for the target Mendix version: dump it with mxcli bson dump and compare.

The vendored metamodel types in modelsdk/gen/<domain>/ are the reference in the tree:

  • types.go: each type’s BSON $Type (the SetTypeName(...) call, e.g. Forms$DivContainer) and its BSON field names
  • refs.go: the reference kind of each pointer property (by name or by ID)

When modelsdk/gen and a Studio Pro document disagree, the document wins.

Type Name Mapping

Mendix uses different prefixes for API names vs storage names:

API PrefixStorage PrefixDomain
Pages$Forms$Page widgets
Microflows$Microflows$Microflow elements
DomainModels$DomainModels$Domain model elements
Texts$Texts$Text/translation elements
DataTypes$DataTypes$Data type definitions
CustomWidgets$CustomWidgets$Pluggable widgets

Common Type Name Mistakes

Incorrect (will fail)Correct Storage Name
Forms$NoClientActionForms$NoAction
Forms$PageClientActionForms$FormAction
Forms$MicroflowClientActionForms$MicroflowAction
Pages$DivContainerForms$DivContainer
Pages$ActionButtonForms$ActionButton

Unit Document Structure

Each model element (entity, microflow, page, etc.) is stored as a BSON document:

graph LR
    subgraph "BSON Document"
        ID["$ID: binary UUID"]
        TYPE["$Type: 'DomainModels$Entity'"]
        NAME["Name: 'Customer'"]
        ATTRS["Attributes: [...]"]
        LOC["Location: '100;200'"]
    end

Array Version Markers

Mendix BSON uses version markers for arrays:

MarkerMeaning
[3]Empty array
[2, item1, item2, ...]Non-empty array with items
[3, item1, item2, ...]Non-empty array (text items)

Example:

"Widgets": [3]           // Empty widgets array
"Widgets": [2, {...}]    // One widget
"Items": [3, {...}]      // One text translation item

Important: These version markers only exist in BSON format, not in JSON templates. In JSON, [2] is an array containing the integer 2, not an empty array with a version marker.

Page Structure

A page document has this top-level structure:

{
  "$ID": "<uuid>",
  "$Type": "Forms$Page",
  "AllowedModuleRoles": [1],
  "Appearance": { ... },
  "Autofocus": "DesktopOnly",
  "CanvasHeight": 600,
  "CanvasWidth": 1200,
  "Documentation": "",
  "Excluded": false,
  "ExportLevel": "Hidden",
  "FormCall": { ... },
  "Name": "PageName",
  "Parameters": [3, ...],
  "PopupCloseAction": "",
  "Title": { ... },
  "Url": "page_url",
  "Variables": [3]
}

Appearance Object

Standard appearance object for widgets:

{
  "$ID": "<uuid>",
  "$Type": "Forms$Appearance",
  "Class": "",
  "DesignProperties": [3],
  "DynamicClasses": "",
  "Style": ""
}

Widget Default Properties

Each widget type requires specific default properties to be serialized. Studio Pro will fail to load the project if required properties are missing.

DivContainer (Container)

{
  "$Type": "Forms$DivContainer",
  "Appearance": { ... },
  "ConditionalVisibilitySettings": null,
  "Name": "",
  "NativeAccessibilitySettings": null,
  "OnClickAction": { "$Type": "Forms$NoAction", ... },
  "RenderMode": "Div",
  "ScreenReaderHidden": false,
  "TabIndex": 0,
  "Widgets": [3]
}

DataView

{
  "$Type": "Forms$DataView",
  "Appearance": { ... },
  "ConditionalEditabilitySettings": null,
  "ConditionalVisibilitySettings": null,
  "DataSource": { ... },
  "Editability": "Always",
  "FooterWidgets": [3],
  "LabelWidth": 3,
  "Name": "",
  "NoEntityMessage": { ... },
  "ReadOnlyStyle": "Control",
  "ShowFooter": true,
  "TabIndex": 0,
  "Widgets": [3]
}

Input Widgets (TextBox, TextArea, DatePicker, etc.)

Input widgets require several non-null properties:

{
  "$Type": "Forms$TextBox",
  "Appearance": { ... },
  "AriaRequired": false,
  "AttributeRef": {
    "$ID": "<uuid>",
    "$Type": "DomainModels$AttributeRef",
    "Attribute": "Module.Entity.AttributeName",
    "EntityRef": null
  },
  "ConditionalEditabilitySettings": null,
  "ConditionalVisibilitySettings": null,
  "Editable": "Always",
  "FormattingInfo": { ... },
  "IsPasswordBox": false,
  "LabelTemplate": { ... },
  "Name": "textBox1",
  "NativeAccessibilitySettings": null,
  "OnChangeAction": { "$Type": "Forms$NoAction", ... },
  "OnEnterAction": { "$Type": "Forms$NoAction", ... },
  "OnEnterKeyPressAction": { "$Type": "Forms$NoAction", ... },
  "OnLeaveAction": { "$Type": "Forms$NoAction", ... },
  "PlaceholderTemplate": { ... },
  "ReadOnlyStyle": "Inherit",
  "TabIndex": 0,
  "Validation": { ... }
}

Required nested objects:

  • AttributeRef – Must have Attribute as fully qualified path (e.g., Module.Entity.AttributeName)
  • FormattingInfo – Required for TextBox and DatePicker
  • PlaceholderTemplate – Required Forms$ClientTemplate object
  • Validation – Required Forms$WidgetValidation object

Attribute Path Resolution

The AttributeRef.Attribute field requires a fully qualified path in the format Module.Entity.AttributeName. When using short attribute names in MDL, the SDK automatically resolves them using the DataView’s entity context.

Client Action Types

Action TypeStorage Name
No ActionForms$NoAction
Save ChangesForms$SaveChangesClientAction
Cancel ChangesForms$CancelChangesClientAction
Close PageForms$ClosePageClientAction
DeleteForms$DeleteClientAction
Show PageForms$FormAction
Call MicroflowForms$MicroflowAction
Call NanoflowForms$CallNanoflowClientAction

Pluggable Widgets (CustomWidget)

Pluggable widgets like ComboBox use CustomWidgets$CustomWidget with a complex nested structure:

{
  "$Type": "CustomWidgets$CustomWidget",
  "Appearance": { ... },
  "Name": "comboBox1",
  "Object": {
    "$Type": "CustomWidgets$WidgetObject",
    "Properties": [2, { ... }],
    "TypePointer": "<binary ID referencing ObjectType>"
  },
  "Type": {
    "$Type": "CustomWidgets$CustomWidgetType",
    "ObjectType": {
      "$Type": "CustomWidgets$WidgetObjectType",
      "PropertyTypes": [2, { ... }]
    },
    "WidgetId": "com.mendix.widget.web.combobox.Combobox"
  }
}

TypePointer References

There are three levels of TypePointer references:

  1. WidgetObject.TypePointer references ObjectType.$ID (the WidgetObjectType)
  2. WidgetProperty.TypePointer references PropertyType.$ID (the WidgetPropertyType)
  3. WidgetValue.TypePointer references ValueType.$ID (the WidgetValueType)
CustomWidgetType
└── ObjectType (WidgetObjectType)           ← WidgetObject.TypePointer
    └── PropertyTypes[]
        └── PropertyType (WidgetPropertyType) ← WidgetProperty.TypePointer
            └── ValueType (WidgetValueType)   ← WidgetValue.TypePointer

If any TypePointer is missing or references an invalid ID, Studio Pro will fail to load the widget.

Widget Size

Each pluggable widget instance contains a full copy of both Type and Object:

ComponentBSON SizeDescription
Type (CustomWidgetType)~54 KBWidget definition with all PropertyTypes
Object (WidgetObject)~34 KBProperty values for all PropertyTypes
Total per widget~88 KB

There is no deduplication within a page. A page with 4 ComboBox widgets requires ~352 KB for widgets alone.

Common Errors

ErrorCauseSolution
“The type cache does not contain a type with qualified name X”Incorrect $Type valueCheck modelsdk/gen or a Studio Pro document for the correct storage name
“No entity configured for the data source”Missing or incorrect DataView DataSourceConfigure Forms$DataViewSource with proper EntityRef
CE0463 “widget definition has changed”Object properties don’t match Type PropertyTypesUse template Object as base, only modify needed properties
“Project uses features that are no longer supported”Missing widget default propertiesInclude all defaults Studio Pro writes for the widget

Files Reference

FilePurpose
mdl/backend/modelsdk/widget_write.goWidget serialization to BSON
mdl/backend/modelsdk/page_write.goPage serialization
mdl/backend/modelsdk/widget_pluggable_write.goPluggable widget templates
mdl/backend/modelsdk/page.goPage deserialization
modelsdk/codec/encoder.goDocument → BSON
modelsdk/codec/decoder.goBSON → document (polymorphic types)
sdk/widgets/loader.goEmbedded template loading
sdk/widgets/templates/mendix-11.6/*.jsonEmbedded widget templates
modelsdk/gen/*/types.goType definitions and storage names

Storage Names vs Qualified Names

Mendix uses different “storage names” in BSON $Type fields than the “qualified names” shown in the TypeScript SDK documentation. Using the wrong name causes a TypeCacheUnknownTypeException when opening the project in Studio Pro.

The Problem

When serializing model elements to BSON, the $Type field must use the storage name, not the qualified name from the SDK documentation. These names differ in ways that are not always predictable.

Domain Prefix Mapping

The most systematic difference is the domain prefix used for page-related types:

API PrefixStorage PrefixDomain
Pages$Forms$Page widgets
Microflows$Microflows$Microflow elements (same)
DomainModels$DomainModels$Domain model elements (same)
Texts$Texts$Text/translation elements (same)
DataTypes$DataTypes$Data type definitions (same)
CustomWidgets$CustomWidgets$Pluggable widgets (same)

The Pages$ to Forms$ renaming reflects Mendix’s historical terminology – “Form” was the original term for “Page”.

Microflow Action Name Mapping

Several microflow actions have storage names that differ significantly from their qualified names:

Qualified Name (SDK/docs)Storage Name (BSON $Type)Note
CreateObjectActionCreateChangeAction
ChangeObjectActionChangeAction
DeleteObjectActionDeleteAction
CommitObjectsActionCommitAction
RollbackObjectActionRollbackAction
AggregateListActionAggregateAction
ListOperationActionListOperationsActionNote the plural
ShowPageActionShowFormAction“Form” was original for “Page”
ClosePageActionCloseFormAction“Form” was original for “Page”

Client Action Name Mapping

Page client actions also have storage name differences:

Incorrect (will fail)Correct Storage Name
Forms$NoClientActionForms$NoAction
Forms$PageClientActionForms$FormAction
Forms$MicroflowClientActionForms$MicroflowAction
Pages$DivContainerForms$DivContainer
Pages$ActionButtonForms$ActionButton

How to Verify Storage Names

When adding new types, always verify the storage name by:

  1. Examining existing MPR files with the mx tool or an SQLite browser
  2. Checking the vendored types in modelsdk/gen/<domain>/types.go
  3. Looking at the read cases in mdl/backend/modelsdk/microflow_read_actions.go

Looking Up a Storage Name

Each generated type registers its BSON $Type with SetTypeName:

grep -rn 'SetTypeName("Forms\$DivContainer")' modelsdk/gen/

Association Parent/Child Pointer Semantics

Mendix BSON uses inverted naming for association pointers, which is counter-intuitive:

BSON FieldPoints ToMDL Keyword
ParentPointerFROM entity (FK owner)FROM Module.Child
ChildPointerTO entity (referenced)TO Module.Parent

For example, CREATE ASSOCIATION Mod.Child_Parent FROM Mod.Child TO Mod.Parent stores:

  • ParentPointer = Child.$ID (the FROM entity owns the foreign key)
  • ChildPointer = Parent.$ID (the TO entity is being referenced)

This affects entity access rules: MemberAccess entries for associations must only be added to the FROM entity (the one stored in ParentPointer). Adding them to the TO entity triggers CE0066 “Entity access is out of date”.

Key Takeaway

When unsure about the correct BSON structure for a new feature, create a working example in Mendix Studio Pro and compare the generated BSON against a known-good reference. A Studio Pro-authored document is the definitive source for storage names and default values.

Widget Template System

Pluggable widgets (DataGrid2, ComboBox, Gallery, etc.) require embedded template definitions for correct BSON serialization. This page explains how the template system works, from extraction through runtime loading and property mapping.

For the user-facing view — how mxcli keeps widget definitions version-correct across Mendix releases, and the mxcli widget describe command for inspecting a widget’s discovered properties and dynamic rules — see Pluggable Widgets Across Versions.

Why Templates Are Needed

Pluggable widgets in Mendix are defined by two BSON components stored inside each widget instance:

  1. Type (CustomWidgets$CustomWidgetType) – defines the widget’s PropertyTypes schema (what properties exist, their value types)
  2. Object (CustomWidgets$WidgetObject) – provides a valid instance with default values for all properties

Both must be present. The Object’s TypePointer references the Type’s $ID, and each WidgetProperty.TypePointer in the Object references the corresponding WidgetPropertyType.$ID in the Type. If these cross-references are broken or any property is missing, Studio Pro reports CE0463 “widget definition changed”.

Building these structures programmatically is error-prone (50+ PropertyTypes, nested ValueTypes, TextTemplate structures, etc.), so mxcli clones them from known-good templates extracted from Studio Pro.

Template Location

Embedded Templates (built into binary)

sdk/widgets/
├── loader.go                      # Template loading with go:embed
├── mpk/mpk.go                     # .mpk ZIP parsing for augmentation
├── definitions/                   # Widget definition files (.def.json)
│   ├── combobox.def.json
│   └── gallery.def.json
└── templates/
    └── mendix-11.6/               # Templates by Mendix version
        ├── combobox.json
        ├── datagrid.json
        ├── gallery.json
        └── datagrid-*-filter.json

User Templates (3-tier priority)

PriorityLocationScope
1 (highest)<project>/.mxcli/widgets/*.jsonProject-specific
2~/.mxcli/widgets/*.jsonGlobal (all projects)
3 (lowest)sdk/widgets/templates/ (embedded)Built-in

Template JSON Structure

Each template file contains both the Type and Object structures converted from BSON to JSON:

{
  "widgetId": "com.mendix.widget.web.combobox.Combobox",
  "name": "Combo box",
  "version": "11.6.0",
  "extractedFrom": "PageTemplates.Customer_NewEdit",
  "type": {
    "$ID": "aa000000000000000000000000000001",
    "$Type": "CustomWidgets$CustomWidgetType",
    "WidgetId": "com.mendix.widget.web.combobox.Combobox",
    "PropertyTypes": [
      {
        "$ID": "aa000000000000000000000000000010",
        "$Type": "CustomWidgets$WidgetPropertyType",
        "PropertyKey": "attributeEnumeration",
        "ValueType": {
          "$ID": "aa000000000000000000000000000011",
          "Type": "Attribute",
          "DefaultValue": ""
        }
      }
    ]
  },
  "object": {
    "$ID": "aa000000000000000000000000000100",
    "$Type": "CustomWidgets$WidgetObject",
    "TypePointer": "aa000000000000000000000000000001",
    "Properties": [
      2,
      {
        "$ID": "aa000000000000000000000000000110",
        "$Type": "CustomWidgets$WidgetProperty",
        "TypePointer": "aa000000000000000000000000000010",
        "Value": {
          "$ID": "aa000000000000000000000000000111",
          "$Type": "CustomWidgets$WidgetValue",
          "Action": { "$Type": "Forms$NoAction", "DisabledDuringExecution": true },
          "AttributeRef": null,
          "DataSource": null,
          "EntityRef": null,
          "Expression": "",
          "PrimitiveValue": "",
          "Selection": "None",
          "TextTemplate": null,
          "Widgets": [2],
          "XPathConstraint": ""
        }
      }
    ]
  }
}

Key Cross-References

Type.PropertyTypes[].$ID  <--  Object.Properties[].TypePointer
    (WidgetPropertyType)       (WidgetProperty points to its type)

Type.$ID  <--  Object.TypePointer
    (CustomWidgetType)     (WidgetObject points to its type)

Type.PropertyTypes[].ValueType.$ID  <--  Object.Properties[].Value.TypePointer
    (ValueType definition)              (WidgetValue points to its value type)

These cross-references are maintained through ID remapping at load time (see below).

Loading Templates at Runtime

Entry Point

bsonType, bsonObject, propertyTypeIDs, objectTypeID, err :=
    widgets.GetTemplateFullBSON(widgetID, mpr.GenerateID, projectPath)

3-Phase Pipeline (loader.go)

Phase 1: Collect IDs and Generate Mapping

collectIDs() recursively walks both type and object JSON, finds every $ID field, and creates a mapping from old template IDs to freshly generated UUIDs:

Template $ID (static)                    -> New UUID (runtime)
"aa000000000000000000000000000001"       -> "a1b2c3d4e5f6..."  (mpr.GenerateID())
"aa000000000000000000000000000010"       -> "f7e8d9c0b1a2..."
...

This ensures each widget instance gets unique IDs while preserving internal cross-references.

Phase 2: Convert Type JSON to BSON

jsonToBSONWithMappingAndObjectType() converts the Type JSON to bson.D, performing three tasks simultaneously:

  1. ID replacement: Every $ID field is looked up in the mapping, converted to binary GUID format via hexToIDBlob() (with Microsoft GUID byte-swap for the first 3 segments)
  2. String ID references: Any 32-char hex string value that appears in the mapping is also converted to binary (these are cross-references between elements)
  3. PropertyTypeIDMap extraction: For each CustomWidgets$WidgetPropertyType node, records:
PropertyTypeIDMap["attributeEnumeration"] = PropertyTypeIDEntry{
    PropertyTypeID: "f7e8d9c0b1a2...",   // new ID of the PropertyType
    ValueTypeID:    "c3d4e5f6a7b8...",   // new ID of the ValueType
    DefaultValue:   "",                   // from ValueType.DefaultValue
    ValueType:      "Attribute",          // from ValueType.Type
    ObjectTypeID:   "...",               // for nested object list properties
    NestedPropertyIDs: {...},            // property IDs within nested ObjectType
}

This map is the bridge between .def.json property keys and the BSON structure.

Phase 3: Convert Object JSON to BSON

jsonToBSONObjectWithMapping() converts the Object JSON using the same ID mapping. Special handling for TypePointer fields ensures they point to the correct new IDs in the Type.

Placeholder Leak Detection

After conversion, containsPlaceholderID() checks for any remaining aa000000-prefix IDs (binary or string). If found, the load fails immediately rather than producing a corrupt MPR.

MPK Augmentation

Before the 3-phase pipeline, augmentFromMPK() checks if the project has a newer version of the widget:

1. FindMPK(projectDir, widgetID)
   -> Scan project/widgets/*.mpk, parse package.xml to match widget ID
2. ParseMPK(mpkPath)
   -> Extract XML property definitions from the .mpk ZIP
3. AugmentTemplate(clone, mpkDef)
   -> Deep-clone the cached template (never mutate cache)
   -> Add properties present in .mpk but missing from template
   -> Remove properties in template but absent from .mpk

This reduces CE0463 errors from widget version drift. The .mpk in the project’s widgets/ folder is the source of truth for which properties should exist.

JSON to BSON Conversion Rules

JSON TypeBSON Type
"string"string
float64 (whole number)int32
float64 (decimal)float64
true/falseboolean
nullnull
[]empty bson.A
[2, ...]bson.A{int32(2), ...} (array with version marker)
32-char hex string in ID mapping[]byte (binary GUID)

Important: Empty arrays in template JSON are [], not [3]. The BSON array version markers (int32(2) for non-empty, int32(3) for empty) are added during widget serialization, not during template loading.

How Operations Modify Template BSON

After loading, the pluggable widget engine applies property mappings. Each operation locates the target WidgetProperty by matching TypePointer:

updateWidgetPropertyValue(obj, propTypeIDs, "datasource", updateFn)
  |
  +-- Look up propTypeIDs["datasource"].PropertyTypeID -> "f7e8d9c0b1a2..."
  |
  +-- Scan obj.Properties[] array
  |     For each WidgetProperty:
  |       matchesTypePointer(prop, "f7e8d9c0b1a2...")
  |       -> prop.TypePointer (binary GUID) -> BlobToUUID() -> compare
  |       -> Match found!
  |
  +-- Extract prop.Value (bson.D)
  +-- Apply updateFn to modify specific fields:
        opAttribute  -> sets Value.AttributeRef
        opAssociation -> sets Value.AttributeRef + Value.EntityRef
        opPrimitive  -> sets Value.PrimitiveValue
        opDatasource -> replaces Value.DataSource
        opSelection  -> sets Value.Selection
        opWidgets    -> replaces Value.Widgets array

All modifications produce new bson.D values (immutable style).

Extracting New Templates

Important: Use Studio Pro-Created Widgets

Always extract templates from widgets that have been created or updated by Studio Pro. Programmatically generated templates often have subtle differences in property ordering, default values, or nested structures (especially TextTemplate properties that require Forms$ClientTemplate structure instead of null).

Extraction Process

  1. Create the widget in Studio Pro – add the widget to a page, configure with default settings
  2. If updating: right-click and select “Update widget” if Studio Pro shows “widget definition has changed”
  3. Extract using mxcli:
# Extract from MPR (manual method)
mxcli bson dump -p App.mpr --type page --object "Module.TestPage" --format json

# Extract skeleton .def.json from .mpk (automated)
mxcli widget extract --mpk widgets/MyWidget.mpk
# Output: .mxcli/widgets/mywidget.def.json
  1. From the JSON dump, extract the Type and Object fields of the CustomWidgets$CustomWidget and save as the template JSON file

Verifying Templates

# Create a test page with the widget
mxcli -p test.mpr -c "CREATE PAGE Test.TestPage ... MYWIDGET ..."

# Check for errors (should have no CE0463)
mxcli docker check -p test.mpr

# Compare BSON structure if issues persist
mxcli bson dump -p test.mpr --type page --object "Test.TestPage" --format ndsl

Debugging CE0463 Errors

If a page fails with CE0463 after creation:

  1. Create the same widget manually in Studio Pro
  2. Extract its BSON with mxcli bson dump --format ndsl
  3. Compare against the mxcli-generated widget’s BSON
  4. Look for:
    • Missing properties (PropertyType exists in Type but no corresponding WidgetProperty in Object)
    • Wrong default values (especially TextTemplate properties that should not be null)
    • Stale properties from an older widget version
  5. Update the template JSON to match, or let MPK augmentation handle version drift

See .claude/skills/debug-bson.md for the detailed BSON debugging workflow.

TextTemplate Property Requirements

Properties with "Type": "TextTemplate" require a proper Forms$ClientTemplate structure – they cannot be null:

"TextTemplate": {
  "$ID": "<guid>",
  "$Type": "Forms$ClientTemplate",
  "Fallback": { "$ID": "<guid>", "$Type": "Texts$Text", "Items": [] },
  "Parameters": [],
  "Template": { "$ID": "<guid>", "$Type": "Texts$Text", "Items": [] }
}

Empty arrays here must be [], not [2].

Key Source Files

FilePurpose
sdk/widgets/loader.goTemplate loading, 3-phase ID remapping, MPK augmentation
sdk/widgets/mpk/mpk.go.mpk ZIP parsing, XML property extraction, FindMPK
sdk/widgets/definitions/*.def.jsonBuilt-in widget definitions
sdk/widgets/templates/mendix-11.6/*.jsonEmbedded BSON templates
mdl/executor/widget_engine.goPluggableWidgetEngine, 6 operations, Build() pipeline
mdl/executor/widget_registry.go3-tier registry, definition validation
mdl/executor/cmd_pages_builder_input.goupdateWidgetPropertyValue(), TypePointer matching
cmd/mxcli/cmd_widget.gomxcli widget extract/list CLI commands

Pluggable Widget Engine

The Pluggable Widget Engine replaces hardcoded Go builder functions with a data-driven system. Widget behavior is described in declarative .def.json files, and a generic engine applies them against BSON templates at build time.

Architecture Overview

MDL Script: COMBOBOX cmbStatus (Label: 'Status', Attribute: Priority)
    │
    ▼
┌─────────────────────────────────────────────────────────────┐
│                    WidgetRegistry                           │
│  3-tier lookup: embedded → ~/.mxcli/widgets/ → .mxcli/     │
│                                                             │
│  "COMBOBOX" → combobox.def.json                             │
│  "GALLERY"  → gallery.def.json                              │
│  "MYWIDGET" → mywidget.def.json  (user-defined)             │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│                PluggableWidgetEngine.Build()                 │
│                                                             │
│  1. Load template   (combobox.json → augment from .mpk)     │
│  2. Select mode     (enum mode vs association mode)          │
│  3. Apply mappings  (set AttributeRef, PrimitiveValue, etc.) │
│  4. Apply child slots  (embed child widgets into BSON)       │
│  5. Assemble widget (CustomWidgets$CustomWidget)             │
│                                                             │
│            OperationRegistry                                │
│  ┌──────────┬───────────┬───────────┬──────────┬─────────┐  │
│  │attribute │association│ primitive │datasource│ widgets │  │
│  │          │           │           │selection │         │  │
│  └──────────┴───────────┴───────────┴──────────┴─────────┘  │
└─────────────────────────────────────────────────────────────┘
                      │
                      ▼
               CustomWidget BSON
         (written to .mpr / .mxunit)

Widget Definitions (.def.json)

Each pluggable widget has a definition file that maps MDL syntax to template properties.

ComboBox Example (two modes)

{
  "widgetId": "com.mendix.widget.web.combobox.Combobox",
  "mdlName": "COMBOBOX",
  "templateFile": "combobox.json",
  "defaultEditable": "Always",
  "modes": [
    {
      "name": "association",
      "condition": "hasDataSource",
      "propertyMappings": [
        {"propertyKey": "optionsSourceType", "value": "association", "operation": "primitive"},
        {"propertyKey": "optionsSourceAssociationDataSource", "source": "DataSource", "operation": "datasource"},
        {"propertyKey": "attributeAssociation", "source": "Association", "operation": "association"},
        {"propertyKey": "optionsSourceAssociationCaptionAttribute", "source": "CaptionAttribute", "operation": "attribute"}
      ]
    },
    {
      "name": "default",
      "propertyMappings": [
        {"propertyKey": "attributeEnumeration", "source": "Attribute", "operation": "attribute"}
      ]
    }
  ]
}
{
  "widgetId": "com.mendix.widget.web.gallery.Gallery",
  "mdlName": "GALLERY",
  "templateFile": "gallery.json",
  "propertyMappings": [
    {"propertyKey": "datasource", "source": "DataSource", "operation": "datasource"},
    {"propertyKey": "itemSelection", "source": "Selection", "operation": "selection", "default": "Single"},
    {"propertyKey": "desktopItems", "source": "DesktopColumns", "default": "1", "operation": "primitive"},
    {"propertyKey": "tabletItems", "source": "TabletColumns", "default": "1", "operation": "primitive"},
    {"propertyKey": "phoneItems", "source": "PhoneColumns", "default": "1", "operation": "primitive"},
    {"propertyKey": "pageSize", "value": "20", "operation": "primitive"}
  ],
  "childSlots": [
    {"propertyKey": "content", "mdlContainer": "TEMPLATE", "operation": "widgets"},
    {"propertyKey": "filtersPlaceholder", "mdlContainer": "FILTER", "operation": "widgets"}
  ]
}

Mode Selection

Modes are evaluated in definition order. The first mode whose condition matches the MDL AST is selected. A mode without a condition is the fallback (default).

ConditionChecks
hasDataSourceAST widget has a DataSource property
hasAttributeAST widget has an Attribute property
hasProp:XYZAST widget has a property named XYZ
(none)Fallback – selected if no other mode matches

Six Built-in Operations

OperationWhat It SetsInput
attributeValue.AttributeRefQualified path (Module.Entity.Attr)
associationValue.EntityRef (IndirectEntityRef + EntityRefStep)Association path + target entity
primitiveValue.PrimitiveValueStatic string value
datasourceValue.DataSourceBSON data source object
selectionValue.Selection"Single", "Multi", or "None"
widgetsValue.Widgets arraySerialized child widget BSON

All operations are registered in an OperationRegistry. Custom operations can be added without modifying the engine.

Mapping Order Dependency

The engine processes property mappings in array order. Some operations depend on side effects:

  • datasource sets pageBuilder.entityContext as a side effect
  • association reads pageBuilder.entityContext to resolve the target entity

Therefore, in any mode using both, datasource must come before association in the mappings array. This is enforced at definition load time – a validation error is raised if an association mapping appears before any datasource mapping.

Which Entity an Attribute Binds To

An attribute property’s entity is not a side effect of mapping order. It is decided per property by the widget’s own schema, the same rule Studio Pro uses (resolveBindingScope in mdl/executor/widget_attribute_scope.go):

  • a property widget.xml links to a datasource (dataSource="parts") binds to that datasource’s entity;
  • a property linked to no datasource binds to the context object — the enclosing data container’s entity — even when the widget has datasources of its own;
  • a property the template does not declare, or a widget with no enclosing context, falls back to the shared entityContext.

Both passes use it: the mapping pass and the explicit-key pass, which re-applies a property authored by its schema key after every datasource has run. Before ako/mxcli#647 that second pass bound every name to the last datasource’s entity, so a two-datasource widget inside a data view failed mx check with CE1613 on each attribute.

An unqualified name that is not an attribute of the entity its property binds to, but is one of another entity in the widget’s scope (the enclosing container’s or one of its datasources’), is refused by exec and by check --references with the same message, naming the candidates (misboundAttributeError, shared by both).

Source/Operation Compatibility

Not all source/operation combinations are valid. These are rejected at load time:

SourceIncompatible Operations
Attributeassociation, datasource
Associationattribute, datasource
DataSourceattribute, association

Version Handling

The Problem

Widget property schemas change between Mendix versions. The Gallery widget has 23 properties in Mendix 10.24 (widget v3.0.1) but 33 in 11.6.3. Writing BSON with wrong properties causes CE0463 (“widget definition changed”).

The Solution: Baseline + Augmentation

Embedded template (11.6.0 baseline)
         │
         ▼
    Deep clone (never mutate cache)
         │
         ▼
    augmentFromMPK()
    ├── FindMPK(projectDir, widgetID)
    │     Scan project/widgets/*.mpk
    │     Match by widget ID from package.xml
    │
    ├── ParseMPK(mpkPath)
    │     Extract property definitions from widget XML
    │     Extract widget version from package.xml
    │
    └── AugmentTemplate(clone, mpkDef)
          Compare template keys vs .mpk keys:
          ├── Missing (in .mpk, not in template) → clone from exemplar or create
          └── Stale (in template, not in .mpk) → remove
         │
         ▼
    Augmented template (matches project's widget version)
         │
         ▼
    3-phase BSON conversion (ID remap → Type → Object)

Why the Baseline Must Be Conservative

The embedded template should be from the oldest supported Mendix version, not the newest:

  • Augmentation adds properties: New properties in a newer .mpk are cloned from type-matching exemplars or created with sensible defaults. This is reliable.
  • Augmentation removes properties: Extra properties from the baseline that don’t exist in an older .mpk are stripped cleanly.
  • Augmentation cannot restructure: If a newer template has fundamentally different property types, nesting, or internal structure, augmentation fails silently. Starting from an older baseline avoids this.
VersionPropertiesAugmentation Action
10.24 (v3.0.1)23Remove 10 stale properties from 11.6 baseline
11.6.023No augmentation needed (matches baseline)
11.6.333Add 10 missing properties from .mpk
11.8.033+Add any new properties from .mpk

Widget Registry (3-Tier)

Definitions are loaded in priority order. Higher-priority definitions override lower ones:

PriorityLocationUse Case
1 (highest)<project>/.mxcli/widgets/*.def.jsonProject-specific widget overrides
2~/.mxcli/widgets/*.def.jsonGlobal user-defined widgets
3 (lowest)sdk/widgets/definitions/ (embedded)Built-in ComboBox, Gallery

Definitions are looked up by MDL name (case-insensitive): COMBOBOX, GALLERY, or any custom name.

DATAGRID has no embedded definition, on purpose. Its .def.json is generated from the project’s own package, and an embedded one would sit ahead of that package for every write (RefreshWidgetDefinitions skips a widget id the registry already holds). So without a project the registry has no definition for it, and mxcli check reads the property list of the template mxcli embeds for the widget (sdk/widgets/templates/, via embeddedSchemaFor): a key the template declares is accepted and its declared type decides whether an expression is allowed (MDL-WIDGET42). The template is the oldest-supported baseline, so a key it does not declare is reported as one that cannot be verified without a project (MDL-WIDGET07), not as one that is dropped.

Validation at Load Time

When a definition is loaded, it is validated:

  • All operation fields must reference a registered operation
  • Source and operation must be compatible (see table above)
  • association mappings must appear after datasource mappings
  • widgetId and mdlName must be non-empty

Invalid definitions produce an immediate error rather than failing silently at build time.

Adding a Custom Widget

Step 1: Extract from .mpk

mxcli widget extract --mpk widgets/MyRatingWidget.mpk
# Creates: .mxcli/widgets/myratingwidget.def.json (skeleton)

The extract command parses the .mpk ZIP, reads the widget XML, and auto-infers operations from property types:

XML TypeInferred Operation
attributeattribute
associationassociation
datasourcedatasource
expression(skipped – manual)
widgetswidgets (child slot)
enumerationprimitive
booleanprimitive
integerprimitive

Step 2: Extract Template

Create the widget in Studio Pro, then extract its BSON:

mxcli bson dump -p App.mpr --type page --object "Module.TestPage" --format json

Save the type and object sections as .mxcli/widgets/myratingwidget.json.

Step 3: Edit the Definition

Adjust the generated .def.json to map MDL properties correctly. Set the mdlName to your desired keyword:

{
  "widgetId": "com.example.widget.RatingWidget",
  "mdlName": "RATING",
  "templateFile": "myratingwidget.json",
  "propertyMappings": [
    {"propertyKey": "ratingAttribute", "source": "Attribute", "operation": "attribute"},
    {"propertyKey": "maxRating", "value": "5", "operation": "primitive"}
  ]
}

Step 4: Use in MDL

CREATE PAGE MyModule.ReviewPage (...) {
  CONTAINER main () {
    RATING starRating (Label: 'Rating', Attribute: Review_Rating)
  }
}

Object-Lists and DataGrid2 Columns

A widget property can be an object-list: a repeated, structured child object rather than a single value or a flat widget slot. DataGrid 2 columns are the canonical example — each column is its own object with a header, an attribute (or custom content), a filter, sortable/width settings, and so on. The engine models this with ObjectListMapping (mdl/executor/widget_engine.go):

  • ItemProperties — scalar sub-properties of each item (attribute, caption/header, showContentAs, sortable, width, …), each with its own operation and MDL aliases.
  • ItemSlots — nested widget slots inside each item, with AcceptedChildTypes. This is how a per-column filter widget (textfilter/numberfilter/datefilter/dropdownfilter) or a custom-content widget lands in the right slot of its column.

DataGrid 2 is built entirely through this generic path when building a page. datagrid has no dedicated case in buildWidgetV3; it resolves through the widget registry (a datagrid.def.json generated on demand from the project’s installed .mpk, with columns emitted as an ObjectListMapping) and builds via buildPluggable. Columns are serialized by Builder.SetObjectList → buildObjectListItemBSON, walking the template’s PropertyType order (the CE0463-safe ordering) and applying the same empty-ClientTemplate/null conventions per column kind. There is no DataGrid-specific full-page builder — Gallery, by contrast, needs only child slots (its items are a single repeated template), while DataGrid needs the object-list machinery because each column is a distinct heterogeneous object carrying a nested filter.

What Stays Hardcoded

Native Mendix widgets (TextBox, DataView, ListView, LayoutGrid, Container, etc.) use Forms$TextBox, Forms$DataView, etc. – NOT CustomWidgets$CustomWidget. These stay as hardcoded builders because they have fundamentally different BSON structures and don’t use the template system.

ALTER PAGE DataGrid 2 column insert/replace. The one part of DataGrid 2 that is not on the generic engine is the in-place column mutation used by alter page … insert/replace column — Mutator.InsertColumns/ReplaceColumn build a single column’s BSON via the hand-coded BuildDataGrid2Column (mdl/backend/widgetobj/datagrid_column.go) and a bespoke BuildFilterWidget, because they splice into an already-serialized grid object rather than building a fresh widget. This duplicates the column conventions the object-list engine already applies; re-expressing it on SetObjectList semantics is a tracked follow-up (see datagrid_column.go). The full-page build path does not use this code.

Key Source Files

FilePurpose
mdl/executor/widget_engine.goPluggableWidgetEngine, OperationRegistry, Build() pipeline
mdl/executor/widget_registry.go3-tier definition loading, validation
sdk/widgets/definitions/*.def.jsonBuilt-in widget definitions
sdk/widgets/loader.goTemplate loading, 3-phase ID remapping
sdk/widgets/augment.goMPK augmentation (add/remove properties)
sdk/widgets/mpk/mpk.go.mpk ZIP parsing, property extraction
sdk/widgets/templates/mendix-11.6/*.jsonEmbedded baseline templates
cmd/mxcli/cmd_widget.gomxcli widget extract/list CLI

MDL Parser

The MDL parser translates SQL-like MDL (Mendix Definition Language) syntax into executable operations against Mendix project files. It uses ANTLR4 for grammar definition, enabling cross-language grammar sharing with other implementations (TypeScript, Java, Python).

Architecture Overview

┌─────────────────────────────────────────────────────────────────────┐
│                         MDL Input String                            │
│              "LIST ENTITIES IN MyModule"                            │
└─────────────────────────────────────────────────────────────────────┘
                                  │
                                  ▼
┌─────────────────────────────────────────────────────────────────────┐
│                    ANTLR4 Lexer (mdl_lexer.go)                      │
│    Generated from MDLLexer.g4 - Tokenizes input into SHOW, ENTITIES,│
│    IN, IDENTIFIER tokens                                            │
└─────────────────────────────────────────────────────────────────────┘
                                  │
                                  ▼
┌─────────────────────────────────────────────────────────────────────┐
│                    ANTLR4 Parser (mdl_parser.go)                    │
│    Generated from MDLParser.g4 - Builds parse tree according to     │
│    grammar rules                                                    │
└─────────────────────────────────────────────────────────────────────┘
                                  │
                                  ▼
┌─────────────────────────────────────────────────────────────────────┐
│                    ANTLR Listener (visitor/visitor.go)              │
│    Walks parse tree and builds strongly-typed AST nodes            │
└─────────────────────────────────────────────────────────────────────┘
                                  │
                                  ▼
┌─────────────────────────────────────────────────────────────────────┐
│                         AST (ast/ast.go)                            │
│    *ast.ShowStmt{Type: "ENTITIES", Module: "MyModule"}             │
└─────────────────────────────────────────────────────────────────────┘
                                  │
                                  ▼
┌─────────────────────────────────────────────────────────────────────┐
│                    Executor (executor/executor.go)                  │
│    Executes AST against modelsdk-go API                            │
└─────────────────────────────────────────────────────────────────────┘
                                  │
                                  ▼
┌─────────────────────────────────────────────────────────────────────┐
│                      modelsdk-go Library                            │
│    mpr.Writer, domainmodel.Entity, etc.                            │
└─────────────────────────────────────────────────────────────────────┘

Directory Structure

mdl/
├── grammar/
│   ├── MDLLexer.g4         # ANTLR4 lexer grammar (tokens)
│   ├── MDLParser.g4        # ANTLR4 parser grammar (rules)
│   └── parser/             # Generated parser code (DO NOT EDIT)
│       ├── mdl_lexer.go
│       ├── mdl_parser.go
│       ├── mdlparser_listener.go
│       └── mdlparser_base_listener.go
├── ast/
│   └── ast.go, ast_microflow.go, ast_expression.go, ast_datatype.go, ...
├── visitor/
│   └── visitor.go          # ANTLR listener implementation
├── executor/
│   ├── executor.go              # AST execution logic
│   ├── cmd_microflows_builder.go  # Microflow builder (variable tracking)
│   └── validate_microflow.go     # AST-level semantic checks (mxcli check)
├── catalog/
│   └── catalog.go          # SQLite-based project metadata catalog
├── linter/
│   ├── linter.go           # Linting framework
│   └── rules/              # Built-in lint rules
└── repl/
    └── repl.go             # Interactive REPL interface

Why ANTLR4?

ConsiderationANTLR4Parser Combinators
Cross-languageSame grammar for Go, TS, JavaRewrite per language
Grammar docsEBNF-like, readableCode is the doc
Error messagesBuilt-in recoveryCustom implementation
PerformanceOptimized lexer/parserComparable
ToolingANTLR Lab, IDE pluginsLimited

Why Listener Pattern (not Visitor)?

MDL uses the listener pattern rather than the visitor pattern:

  • Listener: Callbacks fired during tree walk, simpler for AST building
  • Visitor: Returns values from each node, better for expression evaluation

For MDL, statements are independent and do not need return value propagation, making the listener pattern more appropriate.

Regenerating the Parser

After modifying MDLLexer.g4 or MDLParser.g4:

make grammar

Or manually:

cd mdl/grammar
antlr4 -Dlanguage=Go -package parser -o parser MDLLexer.g4 MDLParser.g4

Requirements:

  • ANTLR4 tool (antlr4 command or Java JAR)
  • Go target runtime (github.com/antlr4-go/antlr/v4)

ANTLR4 Grammar Design

The MDL parser uses ANTLR4 with separate lexer and parser grammars. This page covers the design patterns and key decisions in the grammar.

Grammar Files

FilePurpose
mdl/grammar/MDLLexer.g4Token definitions (keywords, operators, literals)
mdl/grammar/MDLParser.g4Parser rules (statements, expressions, blocks)
mdl/grammar/parser/Generated Go code (do not edit)

Regenerating the Parser

After modifying either .g4 file:

make grammar

This runs the ANTLR4 tool to regenerate the Go parser code in mdl/grammar/parser/.

Key Design Patterns

Case-Insensitive Keywords

MDL keywords are case-insensitive. This is implemented using fragment rules in the lexer:

// Lexer fragments for case-insensitive matching
fragment A : [aA] ;
fragment B : [bB] ;
// ...

// Keywords use fragments
SHOW    : S H O W ;
CREATE  : C R E A T E ;
ENTITY  : E N T I T Y ;

This means SHOW, show, Show, and sHoW are all valid.

Separate Lexer and Parser

The grammar uses split lexer/parser grammars (not a combined grammar). This provides:

  • Clearer separation between tokenization and parsing
  • Better control over token modes
  • Easier maintenance of complex grammars

Statement Structure

Top-level statements follow a consistent pattern:

statement
    : showStatement
    | describeStatement
    | createStatement
    | alterStatement
    | dropStatement
    | grantStatement
    | revokeStatement
    | searchStatement
    | refreshStatement
    | sqlStatement
    | importStatement
    // ...
    ;

Each statement type has its own rule, keeping the grammar modular.

Qualified Names

Module-qualified names (Module.Entity) are a fundamental pattern:

qualifiedName
    : IDENTIFIER ('.' IDENTIFIER)*
    ;

This matches Customer, Sales.Customer, and Sales.Customer.Name.

Widget Trees

Page definitions use recursive widget nesting with curly braces:

widgetDecl
    : widgetType IDENTIFIER widgetProperties? widgetBody?
    ;

widgetBody
    : '{' widgetDecl* '}'
    ;

This allows arbitrary nesting depth for layout grids, containers, and data views.

Architecture Pipeline

MDL Input String
       │
       ▼
┌──────────────┐
│ ANTLR4 Lexer │  Tokenizes into SHOW, IDENTIFIER, etc.
└──────┬───────┘
       │
       ▼
┌──────────────┐
│ ANTLR4 Parser│  Builds parse tree from grammar rules
└──────┬───────┘
       │
       ▼
┌──────────────┐
│   Visitor    │  Walks parse tree, builds typed AST nodes
│  (Listener)  │  (mdl/visitor/visitor.go)
└──────┬───────┘
       │
       ▼
┌──────────────┐
│     AST      │  Strongly-typed statement nodes
│              │  (mdl/ast/ast.go)
└──────┬───────┘
       │
       ▼
┌──────────────┐
│   Executor   │  Executes AST against modelsdk-go
│              │  (mdl/executor/executor.go)
└──────────────┘

Visitor (Listener) Pattern

ANTLR4 generates a listener interface. The MDL visitor (mdl/visitor/visitor.go) implements this interface to build AST nodes:

  • EnterCreateEntityStatement -> creates *ast.CreateEntityStmt
  • EnterCreateMicroflowStatement -> creates *ast.CreateMicroflowStmt
  • EnterShowStatement -> creates *ast.ShowStmt

Each listener method extracts data from the parse tree context and constructs the corresponding AST node.

Error Recovery

The ANTLR4 parser provides built-in error recovery. When a syntax error is encountered:

  1. The parser reports the error with line/column position
  2. It attempts to recover by consuming or inserting tokens
  3. Parsing continues to find additional errors

This enables the language server to report multiple syntax errors in a single pass.

Adding New Syntax

See Adding New Statements for the step-by-step process of extending the grammar.

Lexer - Parser - AST - Executor Pipeline

The complete parsing pipeline from raw MDL text through lexical analysis, parsing, AST construction, and execution against the model.

Pipeline Flow

flowchart TD
    INPUT[/"LIST ENTITIES IN MyModule"/]

    subgraph Parsing
        LEXER[ANTLR4 Lexer]
        PARSER[ANTLR4 Parser]
        TREE[Parse Tree]
    end

    subgraph "AST Building"
        VISITOR[AST Visitor]
        AST[ShowStmt AST Node]
    end

    subgraph Execution
        EXEC[Executor]
        SDK[SDK API Call]
        FORMAT[Format Output]
    end

    OUTPUT[/Markdown Table/]

    INPUT --> LEXER
    LEXER --> PARSER
    PARSER --> TREE
    TREE --> VISITOR
    VISITOR --> AST
    AST --> EXEC
    EXEC --> SDK
    SDK --> FORMAT
    FORMAT --> OUTPUT

Stage 1: ANTLR4 Grammar

The grammar defines MDL syntax using ANTLR4’s EBNF-like notation.

Case-Insensitive Keywords

Uses fragment rules for case-insensitive matching:

// Keywords are case-insensitive
SHOW    : S H O W ;
ENTITY  : E N T I T Y ;

// Fragment rules for each letter
fragment S : [sS] ;
fragment H : [hH] ;
fragment O : [oO] ;
fragment W : [wW] ;
// ... etc

This allows SHOW, show, Show, etc. to all match the same token.

Labeled Alternatives

Parser rules use labeled alternatives for type-safe listener methods:

showStatement
    : LIST MODULES SEMI?                       # ShowModules
    | LIST ENTITIES (IN IDENTIFIER)? SEMI?     # ShowEntities
    | SHOW ENTITY qualifiedName SEMI?          # ShowEntity
    ;

Each label generates a specific listener method (e.g., EnterShowModules, EnterShowEntities).

Whitespace Handling

Whitespace is sent to a hidden channel (skipped):

WS : [ \t\r\n]+ -> skip ;

Stage 2: Generated Parser

ANTLR4 generates four files from the grammar:

FilePurpose
mdl_lexer.goTokenizer – converts input to token stream
mdl_parser.goParser – builds parse tree from tokens
mdl_listener.goListener interface – callbacks for each rule
mdl_base_listener.goEmpty listener implementation for extension

Stage 3: AST Types

Strongly-typed AST nodes representing MDL statements:

// Statement is the interface for all MDL statements
type Statement interface {
    statementNode()
}

// ShowStmt represents SHOW commands
type ShowStmt struct {
    Type   string        // MODULES, ENTITIES, ASSOCIATIONS, ENUMERATIONS
    Module string        // Optional: filter by module
    Name   QualifiedName // For SHOW ENTITY/ASSOCIATION
}

// CreateEntityStmt represents CREATE ENTITY
type CreateEntityStmt struct {
    Name        QualifiedName
    Persistent  bool
    Attributes  []Attribute
    Position    *Position
    Comment     string
    Doc         string
}

// QualifiedName represents Module.Name or just Name
type QualifiedName struct {
    Module string
    Name   string
}

Stage 4: ANTLR Listener (Visitor)

The visitor walks the ANTLR parse tree and builds AST nodes.

Type Assertions for Context Access

func (v *Visitor) EnterShowEntities(ctx *parser.ShowEntitiesContext) {
    stmt := &ast.ShowStmt{Type: "ENTITIES"}

    // Access IDENTIFIER token if present (IN clause)
    if id := ctx.IDENTIFIER(); id != nil {
        stmt.Module = id.GetText()
    }

    v.program.Statements = append(v.program.Statements, stmt)
}

Building Qualified Names

func buildQualifiedName(ctx parser.IQualifiedNameContext) ast.QualifiedName {
    qn := ctx.(*parser.QualifiedNameContext)
    ids := qn.AllIDENTIFIER()

    if len(ids) == 1 {
        return ast.QualifiedName{Name: ids[0].GetText()}
    }
    return ast.QualifiedName{
        Module: ids[0].GetText(),
        Name:   ids[1].GetText(),
    }
}

Error Handling

Syntax errors are collected via a custom error listener:

type ErrorListener struct {
    *antlr.DefaultErrorListener
    Errors []error
}

func (e *ErrorListener) SyntaxError(recognizer antlr.Recognizer, offendingSymbol interface{},
    line, column int, msg string, ex antlr.RecognitionException) {
    e.Errors = append(e.Errors, fmt.Errorf("line %d:%d %s", line, column, msg))
}

Stage 5: Executor

Executes AST statements against the modelsdk-go API:

type Executor struct {
    writer *mpr.Writer
    output io.Writer
}

func (e *Executor) Execute(stmt ast.Statement) error {
    switch s := stmt.(type) {
    case *ast.ConnectStmt:
        return e.executeConnect(s)
    case *ast.ShowStmt:
        return e.executeShow(s)
    case *ast.CreateEntityStmt:
        return e.executeCreateEntity(s)
    // ... other statement types
    }
}

Integration with SDK

func (e *Executor) executeCreateEntity(stmt *ast.CreateEntityStmt) error {
    // Build domain model entity
    entity := &domainmodel.Entity{
        ID:   mpr.GenerateID(),
        Name: stmt.Name.Name,
        // ... other fields
    }

    // Get module and add entity
    module := e.getOrCreateModule(stmt.Name.Module)
    dm := module.DomainModel
    dm.Entities = append(dm.Entities, entity)

    return nil
}

Stage 6: REPL

Interactive read-eval-print loop ties the pipeline together:

type REPL struct {
    executor *executor.Executor
    input    io.Reader
    output   io.Writer
}

func (r *REPL) Run() error {
    scanner := bufio.NewScanner(r.input)
    for {
        fmt.Fprint(r.output, "mdl> ")
        if !scanner.Scan() {
            break
        }

        input := scanner.Text()
        prog, errs := visitor.Build(input)
        if len(errs) > 0 {
            // Handle parse errors
            continue
        }

        for _, stmt := range prog.Statements {
            if err := r.executor.Execute(stmt); err != nil {
                fmt.Fprintf(r.output, "Error: %v\n", err)
            }
        }
    }
    return nil
}

Extending the Parser

Adding a New Statement Type

  1. Update grammar (MDLLexer.g4 for tokens, MDLParser.g4 for rules):
ddlStatement
    : createStatement
    | newStatement      // Add new statement
    ;

newStatement
    : NEW KEYWORD qualifiedName SEMI?    # NewKeyword
    ;

NEW : N E W ;
KEYWORD : K E Y W O R D ;
  1. Regenerate parser:
make grammar
  1. Add AST type (ast/ast.go):
type NewKeywordStmt struct {
    Name QualifiedName
}

func (*NewKeywordStmt) statementNode() {}
  1. Update visitor (visitor/visitor.go):
func (v *Visitor) EnterNewKeyword(ctx *parser.NewKeywordContext) {
    stmt := &ast.NewKeywordStmt{
        Name: buildQualifiedName(ctx.QualifiedName()),
    }
    v.program.Statements = append(v.program.Statements, stmt)
}
  1. Update executor (executor/executor.go):
func (e *Executor) Execute(stmt ast.Statement) error {
    switch s := stmt.(type) {
    // ... existing cases
    case *ast.NewKeywordStmt:
        return e.executeNewKeyword(s)
    }
}

Microflow Validation

Before execution, mxcli check runs AST-level semantic checks on microflow bodies via ValidateMicroflow(). These checks operate purely on the parsed AST and require no project connection:

  1. Return value consistency – RETURN must provide a value when the microflow declares a return type
  2. Return type plausibility – Scalar literals cannot be returned from entity-typed microflows
  3. Return path coverage – All code paths must end with RETURN for non-void microflows
  4. Variable scope – Variables declared inside IF/ELSE branches cannot be referenced after the branch ends
  5. Validation feedback – VALIDATION FEEDBACK must have a non-empty message template

Handling Nil Values from ANTLR

ANTLR parsers can return partial parse trees with nil nodes when there are syntax errors. Always check if grammar element getters return nil before calling methods on them:

// DANGEROUS - will panic if AttributeName() returns nil
attr.Name = a.AttributeName().GetText()

// SAFE - check for nil first
if a.AttributeName() == nil {
    b.addErrorWithExample(
        "Invalid attribute: each attribute must have a name and type",
        `  CREATE PERSISTENT ENTITY MyModule.Customer (
    Name: String(100) NOT NULL,
    Email: String(200),
    Age: Integer
  );`)
    continue
}
attr.Name = a.AttributeName().GetText()

Common ANTLR context methods that can return nil on parse errors:

  • AttributeName() – missing attribute identifier
  • EnumValueName() – missing enumeration value identifier
  • QualifiedName() – missing or malformed qualified name
  • DataType() – missing type specification
  • Expression() – missing or malformed expression

Adding New Statements

Step-by-step guide for adding a new MDL statement to the parser and executor.

Overview

Adding a new statement requires changes in five layers:

  1. Grammar – lexer tokens and parser rules
  2. AST – typed node struct
  3. Visitor – parse tree to AST conversion
  4. Executor – AST to SDK call
  5. Tests – parser and execution tests

Step 1: Grammar Rules

Add Tokens (if needed)

In mdl/grammar/MDLLexer.g4, add any new keywords:

WORKFLOW : W O R K F L O W ;

Add Parser Rule

In mdl/grammar/MDLParser.g4, add the statement rule:

// Add to the statement alternatives
statement
    : ...
    | createWorkflowStatement
    ;

// Define the new statement
createWorkflowStatement
    : CREATE WORKFLOW qualifiedName
      '(' workflowBody ')'
    ;

workflowBody
    : workflowActivity (workflowActivity)*
    ;

workflowActivity
    : USER_TASK IDENTIFIER STRING_LITERAL
    ;

Regenerate Parser

make grammar

Step 2: AST Node

In mdl/ast/, create or extend an AST file:

// ast/ast_workflow.go
package ast

type CreateWorkflowStmt struct {
    Name       QualifiedName
    Activities []*WorkflowActivity
}

type WorkflowActivity struct {
    Type string
    Name string
    Label string
}

Step 3: Visitor Implementation

In mdl/visitor/visitor.go, implement the listener method:

func (v *MDLVisitor) EnterCreateWorkflowStatement(ctx *parser.CreateWorkflowStatementContext) {
    stmt := &ast.CreateWorkflowStmt{
        Name: v.visitQualifiedName(ctx.QualifiedName()),
    }

    for _, actCtx := range ctx.AllWorkflowActivity() {
        activity := &ast.WorkflowActivity{
            Type:  "UserTask",
            Name:  actCtx.IDENTIFIER().GetText(),
            Label: unquote(actCtx.STRING_LITERAL().GetText()),
        }
        stmt.Activities = append(stmt.Activities, activity)
    }

    v.statements = append(v.statements, stmt)
}

Step 4: Executor Handler

In mdl/executor/, create the execution handler:

// executor/cmd_workflows.go
func (e *Executor) execCreateWorkflow(stmt *ast.CreateWorkflowStmt) error {
    module, err := e.findModule(stmt.Name.Module)
    if err != nil {
        return err
    }

    // Build the workflow using SDK types
    workflow := &workflows.Workflow{
        Name: stmt.Name.Name,
        // ... populate from stmt
    }

    return e.writer.CreateWorkflow(module.ID, workflow)
}

Register the handler in executor.go:

func (e *Executor) Execute(stmt ast.Statement) error {
    switch s := stmt.(type) {
    // ... existing cases
    case *ast.CreateWorkflowStmt:
        return e.execCreateWorkflow(s)
    }
}

Step 5: Tests

Parser Test

Verify the grammar parses correctly:

func TestParseCreateWorkflow(t *testing.T) {
    input := `CREATE WORKFLOW Sales.ApprovalProcess (
        USER_TASK reviewTask 'Review Order'
    );`
    stmts, err := parser.Parse(input)
    require.NoError(t, err)
    require.Len(t, stmts, 1)

    ws := stmts[0].(*ast.CreateWorkflowStmt)
    assert.Equal(t, "Sales", ws.Name.Module)
    assert.Equal(t, "ApprovalProcess", ws.Name.Name)
}

Execution Test

Test against a real MPR file:

func TestExecuteCreateWorkflow(t *testing.T) {
    writer := setupTestProject(t)
    defer writer.Close()

    err := executor.ExecuteString(writer,
        `CREATE WORKFLOW TestModule.MyWorkflow (...);`)
    require.NoError(t, err)
}

Checklist

  • Lexer tokens added (if new keywords)
  • Parser rule added
  • Parser regenerated (make grammar)
  • AST node defined
  • Visitor listener implemented
  • Executor handler registered
  • Parser tests pass
  • Execution tests pass
  • mxcli check validates the new syntax
  • Studio Pro opens the modified project without errors

Catalog System

The catalog is an in-memory SQLite database that indexes Mendix project metadata for fast querying, full-text search, and cross-reference navigation.

Purpose

Reading a Mendix project requires parsing BSON documents from the MPR file. The catalog pre-processes this data into relational tables, enabling:

  • SQL queries against project metadata (SELECT ... FROM CATALOG.ENTITIES)
  • Full-text search across all strings and source (SEARCH 'keyword')
  • Cross-reference tracking (callers, callees, references, impact analysis)
  • Lint rule evaluation (Starlark rules query catalog tables)

Building the Catalog

The catalog is populated by the REFRESH CATALOG command:

-- Quick refresh: basic tables (modules, entities, microflows, pages, etc.)
REFRESH CATALOG;

-- Full refresh: adds cross-references, widgets, and source tables
REFRESH CATALOG FULL;

The full refresh is required for:

  • SHOW CALLERS / SHOW CALLEES / SHOW REFERENCES / SHOW IMPACT
  • LIST WIDGETS / UPDATE WIDGETS
  • SELECT ... FROM CATALOG.REFS
  • SELECT ... FROM CATALOG.SOURCE

Querying the Catalog

Use standard SQL syntax with the CATALOG. prefix:

-- Find large microflows
SELECT Name, ActivityCount
FROM CATALOG.MICROFLOWS
WHERE ActivityCount > 10
ORDER BY ActivityCount DESC;

-- Find all entity usages
SELECT SourceName, RefKind, TargetName
FROM CATALOG.REFS
WHERE TargetName = 'MyModule.Customer';

-- Search the strings table
SELECT * FROM CATALOG.STRINGS
WHERE strings MATCH 'error'
LIMIT 10;

Available Tables

TableContentsPopulated By
MODULESModule names and IDsREFRESH CATALOG
ENTITIESEntity names, persistence, attribute countsREFRESH CATALOG
MICROFLOWSMicroflow names, activity counts, parametersREFRESH CATALOG
NANOFLOWSNanoflow names and metadataREFRESH CATALOG
PAGESPage names, layouts, URLsREFRESH CATALOG
SNIPPETSSnippet namesREFRESH CATALOG
ENUMERATIONSEnumeration names and value countsREFRESH CATALOG
CONSTANTSConstant names, types, default valuesREFRESH CATALOG
CONSTANT_VALUESPer-configuration constant overridesREFRESH CATALOG
WORKFLOWSWorkflow names and activity countsREFRESH CATALOG
ACTIVITIESIndividual microflow activitiesREFRESH CATALOG FULL
WIDGETSWidget instances across all pagesREFRESH CATALOG FULL
REFSCross-references between documentsREFRESH CATALOG FULL
PERMISSIONSSecurity permissions (entity access, microflow access)REFRESH CATALOG FULL
STRINGSFTS5 full-text search indexREFRESH CATALOG FULL
SOURCEMDL source text for all documentsREFRESH CATALOG FULL

Implementation

The catalog is implemented in mdl/catalog/catalog.go. It:

  1. Creates an in-memory SQLite database
  2. Iterates all modules and documents in the project
  3. Inserts rows into the appropriate tables
  4. Builds FTS5 indexes for full-text search
  5. Builds the reference graph for cross-reference queries

The catalog lives for the duration of the mxcli session and is discarded on exit.

SQLite Schema

The catalog uses an in-memory SQLite database with the following table definitions.

Core Tables

MODULES

CREATE TABLE modules_data (
    Id                       TEXT PRIMARY KEY,
    Name                     TEXT,
    QualifiedName            TEXT,
    ModuleName               TEXT,
    Folder                   TEXT,
    Description              TEXT,   -- always empty: a module has no documentation
    Source                   TEXT,   -- '' or 'Marketplace v…'
    AppStoreVersion          TEXT,
    AppStoreGuid             TEXT,
    DomainModelDocumentation TEXT,   -- the module's domain model's documentation
    ProjectId                TEXT,
    SnapshotId               TEXT
);

ASSOCIATIONS

CREATE TABLE associations_data (
    Id                     TEXT PRIMARY KEY,
    Name                   TEXT,
    QualifiedName          TEXT,
    ModuleName             TEXT,
    FromEntity             TEXT,   -- Mendix ParentPointer (owns the reference)
    ToEntity               TEXT,   -- Mendix ChildPointer
    AssociationType        TEXT,
    Owner                  TEXT,
    StorageFormat          TEXT,
    Description            TEXT,
    ToDeleteBehavior       TEXT,   -- Mendix DeleteBehavior.ChildDeleteBehavior
    FromDeleteBehavior     TEXT,   -- Mendix DeleteBehavior.ParentDeleteBehavior
    ToDeleteErrorMessage   TEXT,   -- ChildErrorMessage text
    FromDeleteErrorMessage TEXT,   -- ParentErrorMessage text
    ProjectId              TEXT,
    SnapshotId             TEXT
);

ENTITIES

CREATE TABLE ENTITIES (
    Name            TEXT PRIMARY KEY,   -- Qualified: Module.Entity
    ModuleName      TEXT,
    EntityName      TEXT,
    Persistent      BOOLEAN,
    AttributeCount  INTEGER,
    Documentation   TEXT
);

MICROFLOWS

Microflows, nanoflows and rules share one table; NANOFLOWS is a view of the nanoflow rows.

CREATE TABLE microflows_data (
    Id                  TEXT PRIMARY KEY,
    Name                TEXT,
    QualifiedName       TEXT,       -- Module.Microflow
    ModuleName          TEXT,
    Folder              TEXT,
    MicroflowType       TEXT,       -- MICROFLOW, NANOFLOW or RULE
    Description         TEXT,
    ReturnType          TEXT,
    ParameterCount      INTEGER,
    ActivityCount       INTEGER,    -- top level only; a loop counts as one
    TotalActivityCount  INTEGER,    -- including loop bodies, at any depth
    Complexity          INTEGER,    -- McCabe
    Excluded            BOOLEAN,
    ProjectId           TEXT,
    SnapshotId          TEXT
);

PAGES

CREATE TABLE PAGES (
    Name        TEXT PRIMARY KEY,
    ModuleName  TEXT,
    PageName    TEXT,
    Layout      TEXT,
    Url         TEXT,
    Documentation TEXT
);

LAYOUTS

CREATE TABLE LAYOUTS (
    Id            TEXT PRIMARY KEY,
    Name          TEXT,
    QualifiedName TEXT,
    ModuleName    TEXT,
    Folder        TEXT,
    LayoutType    TEXT,   -- Responsive / Phone / Tablet / ModalPopup / Default / Popup
    Platform      TEXT,   -- 'Web' or 'Native' (the content wrapper's type)
    Description   TEXT,
    Class         TEXT,   -- the layout's own Appearance (full build only)
    Style         TEXT,
    DynamicClasses TEXT
);

SNIPPETS

CREATE TABLE SNIPPETS (
    Name        TEXT PRIMARY KEY,
    ModuleName  TEXT,
    SnippetName TEXT
);

ENUMERATIONS

CREATE TABLE ENUMERATIONS (
    Name        TEXT PRIMARY KEY,
    ModuleName  TEXT,
    EnumName    TEXT,
    ValueCount  INTEGER
);

CONSTANTS

CREATE TABLE CONSTANTS (
    Id              TEXT PRIMARY KEY,
    Name            TEXT,
    QualifiedName   TEXT,
    ModuleName      TEXT,
    Folder          TEXT,
    Description     TEXT,
    DataType        TEXT,               -- String, Integer, Boolean, etc.
    DefaultValue    TEXT,
    ExposedToClient INTEGER DEFAULT 0
);

CONSTANT_VALUES

Per-configuration constant overrides. Join with CONSTANTS on ConstantName = QualifiedName.

CREATE TABLE CONSTANT_VALUES (
    Id                INTEGER PRIMARY KEY AUTOINCREMENT,
    ConstantName      TEXT NOT NULL,     -- Qualified: Module.Constant
    ConfigurationName TEXT NOT NULL,     -- e.g., "Default", "Production"
    Value             TEXT
);

WORKFLOWS

CREATE TABLE WORKFLOWS (
    Name            TEXT PRIMARY KEY,
    ModuleName      TEXT,
    WorkflowName    TEXT,
    ActivityCount   INTEGER
);

Full Refresh Tables

These tables are only populated by REFRESH CATALOG FULL.

ACTIVITIES

One row per object of a flow body, loop bodies included (ParentLoopId names the enclosing loop; filter on ParentLoopId = '' for the top level only).

CREATE TABLE activities_data (
    Id                      TEXT PRIMARY KEY,
    Name                    TEXT,       -- ActionType for an action, else ActivityType
    Caption                 TEXT,       -- stored caption / annotation text
    ActivityType            TEXT,       -- e.g. "ActionActivity", "ExclusiveSplit", "LoopedActivity"
    Sequence                INTEGER,    -- pre-order position within the flow
    MicroflowId             TEXT,
    MicroflowQualifiedName  TEXT,
    ModuleName              TEXT,
    Folder                  TEXT,
    EntityRef               TEXT,       -- create object / database retrieve / delete entity
    ActionType              TEXT,       -- e.g. "RetrieveAction", "MicroflowCallAction"
    ServiceRef              TEXT,       -- called service (REST, web service, OData)
    ActionRef               TEXT,       -- operation within it, or the called microflow/nanoflow/Java/JavaScript action
    UseRequestTimeout       INTEGER,
    TimeoutExpression       TEXT,
    Description             TEXT,       -- documentation
    ParentLoopId            TEXT,       -- enclosing loop's Id; '' at the top level
    LoopDepth               INTEGER,    -- 0 at the top level
    AutoGenerateCaption     INTEGER,
    ConditionExpression     TEXT,       -- exclusive split expression
    ConditionRule           TEXT,       -- rule called by a rule-based split
    ErrorHandlingType       TEXT,       -- Rollback / Custom / CustomWithoutRollBack / Continue / Abort
    LogLevel                TEXT,
    LogNodeExpression       TEXT,
    LogMessage              TEXT,
    CommitType              TEXT,       -- Yes / YesWithoutEvents / No
    WithEvents              INTEGER,
    RetrieveSource          TEXT,       -- database / association
    QueueRef                TEXT,       -- task queue a microflow/Java action call runs in
    ProjectId               TEXT,
    SnapshotId              TEXT
);

WIDGETS

One row per widget instance; written by refresh catalog full only.

CREATE TABLE WIDGETS (
    Id                      TEXT PRIMARY KEY,
    Name                    TEXT,
    WidgetType              TEXT,    -- "Forms$TextBox", or a pluggable widget's id
    ContainerId             TEXT,    -- the page, snippet or layout
    ContainerQualifiedName  TEXT,
    ContainerType           TEXT,    -- "PAGE", "SNIPPET" or "LAYOUT"
    ModuleName              TEXT,
    Folder                  TEXT,
    EntityRef               TEXT,
    AttributeRef            TEXT,
    MicroflowRef            TEXT,
    NanoflowRef             TEXT,
    PageRef                 TEXT,
    SnippetRef              TEXT,    -- snippet a snippet call places
    Description             TEXT,
    ParentWidgetId          TEXT,    -- nearest catalogued ancestor; '' at the root
    Depth                   INTEGER, -- catalogued ancestors; 0 at the root
    Class                   TEXT,    -- Appearance
    Style                   TEXT,
    DynamicClasses          TEXT,
    ActionType              TEXT,    -- $Type of Action / OnClickAction / ClickAction
    HasConfirmation         INTEGER, -- 1 when that action has a ConfirmationInfo
    Caption                 TEXT,    -- own Caption, project's default language
    Tooltip                 TEXT,    -- own Tooltip, project's default language
    ProjectId               TEXT,
    SnapshotId              TEXT
);

The tree columns (schema 19) skip what the walk does not index: the synthetic conditionalVisibilityWidget… container, layout grid rows and columns, tab pages, and a pluggable widget’s properties and object-list items, so a widget in a data grid 2 column has the grid as its parent. A list view template is indexed (it carries its own entity) and so is a level of its own. The walk does not follow a snippet call; the snippet’s widgets are rows of the snippet, depth 0 at its root. HasConfirmation can only be 1 for a microflow, nanoflow or workflow call — Forms$DeleteClientAction has no confirmation property.

REFS

The reference graph: one row per edge. Populated by refresh catalog full.

CREATE TABLE REFS (
    Id          INTEGER PRIMARY KEY AUTOINCREMENT,
    SourceType  TEXT NOT NULL,  -- "MICROFLOW", "PAGE", "ENTITY", ...
    SourceId    TEXT NOT NULL,  -- element $ID, or '' where the builder has no id
    SourceName  TEXT NOT NULL,  -- referencing document, module-qualified
    TargetType  TEXT NOT NULL,  -- "ENTITY", "MICROFLOW", "WIDGET", ...
    TargetId    TEXT,           -- element $ID, or the widget ID for a WIDGET target
    TargetName  TEXT NOT NULL,  -- referenced element
    RefKind     TEXT NOT NULL,  -- see the vocabulary below
    ModuleName  TEXT,
    ProjectId   TEXT,
    SnapshotId  TEXT
);

CREATE INDEX idx_refs_source ON refs(SourceType, SourceName);
CREATE INDEX idx_refs_target ON refs(TargetType, TargetName);
CREATE INDEX idx_refs_kind   ON refs(RefKind);

RefKind values are lower-case, and the current vocabulary is whatever CATALOG.GRAPH_REFKIND_DISTRIBUTION reports for your project — query that rather than trusting a list here:

RefKindEdge
callflow calls a microflow / nanoflow / rule / Java action / REST operation; a workflow calls a handler microflow; a consumed OData service calls its configuration / headers / error-handling microflow; an agent calls its tool microflow, nanoflow or agent; a flow passes a microflow or agent document as a Java action parameter, which the action runs
create / change / delete / retrieveflow acts on an entity object
commitflow commits an entity object: a commit action, or a create / change that commits (Yes or YesWithoutEvents), beside its create / change edge
returnflow returns an entity type
parameterpage, snippet or flow parameter entity type
generalizeentity extends entity
associateassociation targets entity
layoutpage uses a layout
datasourcepage or widget reads an entity
actionwidget calls a microflow / nanoflow
show_pageflow or widget action opens a page
snippet_callpage, snippet or layout places a snippet
home_page / login_page / menu_itemnavigation profile references a page, or a microflow (home microflow, menu item that calls one)
calculatecalculated attribute uses a microflow
schedulescheduled event runs a microflow
publishpublished service runs a microflow: a REST operation, an OData resource’s read / insert / update / delete microflow, an OData action, a REST or OData service’s custom authentication microflow; a published MCP service’s tool microflow and authentication microflow
eventevent handler runs a microflow: an entity event, or a business event a service subscribes to
settingsa project setting (after-startup / before-shutdown / health check) names a microflow
syncoffline navigation profile synchronizes an entity
mapping_callflow applies an import or export mapping: an import / export action, or a REST or web service call’s request or response mapping
structureimport or export mapping is built on a JSON structure
toolagent draws on a knowledge base or consumed MCP service (a tool microflow, nanoflow or agent is a call)
modelagent runs on an AI model
keyAI model or knowledge base reads its key from a constant
endpointconsumed MCP service reads its server URL from a constant (its credentials microflow is a call)
validateattribute validation rule uses a regular expression
memberdocument binds, reads or writes an attribute, navigates an association, or reaches the entity an association step arrives at
xpathXPath constraint names an attribute, association, enumeration value, or an entity reached through an association
typeattribute, parameter or variable typed as an enumeration; an entity named as a type (an action’s result variable, a created list, an entity-type argument to a Java or JavaScript action)
valueexpression names an enumeration value; an ENUMERATION target when the value named does not exist, so the document is rebuilt when it is added
widgetpage or snippet uses a pluggable / custom widget

change, delete and commit act on a variable, so the entity is resolved within the flow — from a parameter, a create or retrieve output, or a loop iterator over one of those. A variable whose entity the flow cannot tell (a microflow call’s result, for one) has no edge. commit is not in the analysis graph: the variable it commits comes from a parameter, create or retrieve that already links the flow to the entity (or to the association it was retrieved over), so it would mostly double existing edges.

schedule, publish, event and settings are entry points: something outside the call graph runs the microflow, so nothing in the model calls it. They are what stops GRAPH_DEAD_ASSETS, LIST CALLERS OF and lint rule QUAL004 from reporting a live scheduled job, API handler or event handler as unused. GRAPH_DEAD_ASSETS and QUAL004 count an edge of any kind; LIST CALLERS OF counts the kinds that invoke. The source of a service’s edge is the service itself (PUBLISHED_ODATA_SERVICE, PUBLISHED_REST_SERVICE, BUSINESS_EVENT_SERVICE, CONSUMED_ODATA_SERVICE) with its catalog id in SourceId; a published REST operation’s edge comes from the operation. sync is not one — it names an entity a profile downloads, which is a use of a type, so SHOW CALLERS excludes it for the same reason it excludes datasource.

WIDGET targets

A widget edge is the odd one out and is worth knowing about before you join against it:

  • TargetName is the widget’s MDL name (COMBOBOX), not its dotted widget ID. The ID is in TargetId. A dotted target would be mis-read as a module by GRAPH_MODULE_COUPLING and friends, which take everything before the first dot as the module name.
  • It is therefore the only TargetName that is not module-qualified — a widget definition belongs to no Mendix module. GRAPH_GOD_NODES excludes WIDGET targets from its asset list for that reason, while still counting a page’s out-degree towards the widgets it uses.
  • Only widgets with a definition get an edge. A built-in Mendix widget (Forms$DynamicText) has none, so it produces no row; use CATALOG.WIDGETS for those.
  • One edge per page x widget, not per widget instance.

PERMISSIONS

CREATE TABLE PERMISSIONS (
    RoleName    TEXT,           -- Module role
    TargetName  TEXT,           -- Entity, microflow, or page
    TargetKind  TEXT,           -- "Entity", "Microflow", "Page"
    Permission  TEXT            -- "Create", "Read", "Write", "Delete", "Execute", "View"
);

Full-Text Search Tables

STRINGS (FTS5)

CREATE VIRTUAL TABLE STRINGS USING fts5(
    QualifiedName,  -- Document qualified name, e.g. MyModule.Home
    ObjectType,     -- Document type, derived from the unit $Type: PAGE,
                    -- PAGE_TEMPLATE, BUILDING_BLOCK, MICROFLOW, ENUMERATION, ...
    StringValue,    -- The string itself
    StringContext,  -- Where it lives. For translatable text this is
                    -- <owner $Type>.<property>, e.g. Forms$ActionButton.Caption.
                    -- Non-translatable strings keep a plain label: page_url,
                    -- log_node, log_message, documentation, rest_path,
                    -- task_name, domain_model_documentation, an agent's
                    -- system_prompt / user_prompt / tool_description, ...
    Language,       -- Language code, empty for non-translatable strings
    ElementId,      -- The owning element's $ID — what distinguishes an
                    -- enumeration's twelve values from each other
    ModuleName,
    WidgetId UNINDEXED -- The widget around the text: a button's caption is
                    -- owned by its Forms$ClientTemplate and its confirmation
                    -- by a Forms$ConfirmationInfo, so ElementId is never the
                    -- button. '' outside a widget, and for non-translatable rows.
                    -- A tab page's caption names the tab page, not in widgets
);

Every Texts$Text in the project is indexed, found by a type-agnostic walk rather than per-document-type extraction, so a caption in a document type mxcli cannot otherwise read is still searchable. That includes Atlas’s design templates (PAGE_TEMPLATE, BUILDING_BLOCK), which are roughly 70% of a stock project’s text and never render in a running app — filter them out with ObjectType when you want only the app’s own strings.

The documentation of an element (an entity, attribute, constant, page, …) is a plain string, not a Texts$Text, so it is added from the catalog tables that already hold it, not by the walk: StringContext is documentation, ObjectType names the element (ENTITY, ATTRIBUTE, CONSTANT, PAGE, …) and QualifiedName is the element’s own (Sales.Order.Total for an attribute). Microflow and workflow documentation is indexed with the same labels. See Full-Text Search for the full list.

An empty translation — a text that exists but is not translated yet — is not a row, so a language’s presence in this table means it is actually translated somewhere.

SOURCE (FTS5)

CREATE VIRTUAL TABLE SOURCE USING fts5(
    name,           -- Document qualified name
    kind,           -- Document type
    source,         -- MDL source representation
    tokenize='porter unicode61'
);

Querying Examples

-- Find entities with many attributes
SELECT Name, AttributeCount FROM CATALOG.ENTITIES
WHERE AttributeCount > 20 ORDER BY AttributeCount DESC;

-- Find all references to an entity
SELECT SourceName, RefKind FROM CATALOG.REFS
WHERE TargetName = 'Sales.Customer';

-- Which pages use a given pluggable widget?
SELECT SourceType, SourceName FROM CATALOG.REFS
WHERE RefKind = 'widget' AND TargetName = 'COMBOBOX';

-- Which installed widget packages does nothing use?
-- (MDL's SELECT has no NOT EXISTS / NOT IN — use an anti-join.)
SELECT d.MdlName, d.WidgetId FROM CATALOG.WIDGET_DEFINITIONS d
LEFT JOIN CATALOG.REFS r ON r.TargetId = d.WidgetId AND r.RefKind = 'widget'
WHERE r.Id IS NULL;

-- Full-text search
SELECT name, kind, snippet(STRINGS, 2, '<b>', '</b>', '...', 20)
FROM CATALOG.STRINGS WHERE strings MATCH 'validation error';

-- Find constants exposed to client
SELECT QualifiedName, DataType, DefaultValue FROM CATALOG.CONSTANTS
WHERE ExposedToClient = 1;

-- Compare constant values across configurations
SELECT c.QualifiedName, cv.ConfigurationName, cv.Value
FROM CATALOG.CONSTANTS c
JOIN CATALOG.CONSTANT_VALUES cv ON c.QualifiedName = cv.ConstantName
ORDER BY c.QualifiedName, cv.ConfigurationName;

FTS5 Full-Text Search

The catalog uses SQLite’s FTS5 extension to provide fast full-text search across all project content.

Overview

Two FTS5 virtual tables are created during REFRESH CATALOG FULL:

TableIndexesUse Case
STRINGSValidation messages, captions, labels, log messagesSEARCH 'keyword'
SOURCEMDL source representation of all documentsCode search

Both use the porter unicode61 tokenizer, which provides:

  • Porter stemming – matches word variants (e.g., “validate” matches “validation”)
  • Unicode support – handles non-ASCII characters correctly

Building the Index

During REFRESH CATALOG FULL, the catalog:

  1. Iterates all documents in the project
  2. Extracts all text content (messages, captions, names, expressions)
  3. Generates the MDL source representation for each document
  4. Inserts into the FTS5 tables

Querying

From MDL

-- Simple keyword search
SEARCH 'validation';

-- Phrase search
SEARCH 'error message';

From SQL

-- Basic match
SELECT name, kind FROM CATALOG.STRINGS
WHERE strings MATCH 'customer';

-- Phrase match
SELECT name, kind FROM CATALOG.STRINGS
WHERE strings MATCH '"email validation"';

-- Boolean operators
SELECT name, kind FROM CATALOG.STRINGS
WHERE strings MATCH 'customer AND NOT deleted';

-- With snippets (highlighted context)
SELECT name, kind,
    snippet(STRINGS, 2, '>>>', '<<<', '...', 20) as context
FROM CATALOG.STRINGS
WHERE strings MATCH 'error'
LIMIT 10;

-- Search MDL source
SELECT name, kind FROM CATALOG.SOURCE
WHERE source MATCH 'RETRIEVE FROM';

FTS5 Query Syntax

FTS5 supports a rich query syntax:

SyntaxMeaning
wordMatch documents containing “word”
"exact phrase"Match exact phrase
word1 AND word2Both words must appear
word1 OR word2Either word may appear
NOT wordExclude documents with this word
word*Prefix match (e.g., “valid*” matches “validation”)
NEAR(word1 word2, 5)Words within 5 tokens of each other

CLI Integration

The SEARCH command and mxcli search subcommand are wrappers around FTS5 queries:

# Search from command line
mxcli search -p app.mpr "validation"

# Pipe-friendly output
mxcli search -p app.mpr "error" -q --format names

# JSON output
mxcli search -p app.mpr "Customer" --json

The search results include:

  • Document type (Microflow, Page, Entity, etc.)
  • Qualified name
  • Matching context snippet

Performance

FTS5 indexes are built in memory and provide sub-millisecond query times for typical project sizes. The index construction itself takes a few seconds for large projects (1000+ documents), which is why it requires the explicit REFRESH CATALOG FULL command.

Reference Tracking

The cross-reference tracking system powers the callers, callees, references, and impact analysis queries. It is built during REFRESH CATALOG FULL and stored in the REFS table.

How References Are Collected

During a full catalog refresh, every document is analyzed for outgoing references:

Document TypeReference Sources
MicroflowsCALL MICROFLOW actions, RETRIEVE data sources, entity parameters, SHOW PAGE actions, association traversals
NanoflowsSame as microflows (client-side)
PagesData source entities, microflow data sources, SHOW PAGE actions, association paths, snippet calls
SnippetsSame as pages
Domain ModelsGeneralization references, association endpoints

Each reference is stored as a row in the REFS table:

INSERT INTO REFS (SourceName, SourceKind, TargetName, TargetKind, RefKind)
VALUES ('Sales.ProcessOrder', 'Microflow', 'Sales.Customer', 'Entity', 'Retrieve');

Query Commands

SHOW CALLERS

Find what calls a given element:

LIST CALLERS OF Sales.ProcessOrder;

Returns all microflows, nanoflows, and pages that reference Sales.ProcessOrder.

Transitive callers follow the call chain recursively:

LIST CALLERS OF Sales.ProcessOrder TRANSITIVE;

SHOW CALLEES

Find what a microflow calls:

LIST CALLEES OF Sales.ProcessOrder;

Returns all microflows, entities, and pages referenced by Sales.ProcessOrder.

SHOW REFERENCES

Find all references to an element:

LIST REFERENCES TO Sales.Customer;

Returns every document that references Sales.Customer in any way (data source, parameter, association, etc.).

SHOW IMPACT

Analyze the impact of changing an element:

LIST IMPACT OF Sales.Customer;

Returns all direct and transitive dependents – everything that would potentially be affected by changing or removing the element.

SHOW CONTEXT

Assemble context for understanding a document:

DESCRIBE CONTEXT OF Sales.ProcessOrder DEPTH 3;

Returns the element itself plus its callers and callees up to the specified depth, providing a focused view of the element’s neighborhood in the dependency graph.

CLI Commands

The same queries are available as CLI subcommands:

# Direct callers
mxcli callers -p app.mpr Sales.ProcessOrder

# Transitive callers
mxcli callers -p app.mpr Sales.ProcessOrder --transitive

# Callees
mxcli callees -p app.mpr Sales.ProcessOrder

# All references
mxcli refs -p app.mpr Sales.Customer

# Impact analysis
mxcli impact -p app.mpr Sales.Customer

# Context assembly
mxcli context -p app.mpr Sales.ProcessOrder --depth 3

Reference Kinds

The RefKind column in the REFS table categorizes the relationship:

RefKindMeaning
CallMicroflow/nanoflow call action
RetrieveDatabase retrieve with entity data source
DataSourcePage/widget data source reference
ShowPageShow page action
ParameterMicroflow parameter type reference
AssociationAssociation traversal
GeneralizationEntity generalization (inheritance)
SnippetCallSnippet embedded in a page
EnumerationEnumeration type reference

Implementation

The reference tracker is implemented in the catalog package (mdl/catalog/catalog.go). It:

  1. Parses each document’s BSON structure
  2. Extracts all qualified name references
  3. Classifies each reference by kind
  4. Inserts into the REFS table with source/target/kind

Transitive queries use recursive SQL CTEs (Common Table Expressions) to follow reference chains.

MCP Backend: How a Script Maps to Tool Calls

mxcli can apply an MDL script in two ways:

  • File (MPR) backend — the default. mxcli opens the .mpr directly, mutates the BSON in process, and writes it back to disk. No network, no running Studio Pro.
  • MCP backend (--mcp) — mxcli connects to a running Studio Pro’s embedded MCP server (“PED”) and applies each change through its tools, so edits land in the live, open model with no save/reload cycle.

The two backends produce the same model, but they have very different cost profiles. The file backend turns one statement into one in-process mutation — effectively free. The MCP backend turns one statement into one or more JSON-RPC tool calls to Studio Pro. This page explains that mapping for domain-model scripts and what it means for latency, token usage, and an LLM’s context window.

The numbers below trace the worked example mdl-examples/doctype-tests/01-mcp-domain-model-examples.mdl — a ~20-statement domain-model script that stays entirely inside the MCP authoring surface. To see the surface for your connected version, run mxcli mcp capabilities -p app.mpr --mcp.

The mapping

Each MDL statement expands into a small, fixed choreography of PED tool calls. The choreography — not the statement count — drives the cost.

MDL statementPED tool callsCalls
create moduleped_create_module1
create enumerationped_create_document + ped_check_errors2
create entity (incl. extends)ped_update_document (add) + ped_check_errors2
… plus each NOT NULL / UNIQUE ruleped_read_document (locate entity) + ped_update_document (add rules)+2
create association2× ped_read_document (resolve parent/child entity refs) + ped_update_document (add) + ped_check_errors4
alter entity (add / rename / set documentation)2× ped_read_document (locate + read live attrs) + ped_update_document + ped_check_errors4
show / describe of a just-modified moduleped_read_document (entities + associations) + an enrichment read for attribute types2
list enumerations (and other reads of unchanged docs)served from the local .mpr0
one-time ped_get_schema per element type (entity, attribute, association, validation rule)fetched once per session, then cached~4 total
initialize handshakeonce per connection1

For a ~20-statement domain-model script like the example, that’s on the order of 60 tool calls — overwhelmingly per-write validation and read-backs, not the statement count. (Run it with --mcp-trace to see the exact tally for your script; the precise number shifts as the example evolves.)

See it yourself. Run any script with --mcp-trace (or --mcp-verbose) to print the actual PED calls each MDL command makes — the runtime view of this table. See Connecting to a Running Studio Pro.

Three things inflate the count

  1. Validation runs after every write. ped_check_errors fires once per write statement, so a large share of the calls are validation alone. This keeps the live model provably consistent at each step, but it is not free.
  2. ALTER is read-modify-write. Because PED cannot change an attribute’s type in place, mxcli first reads the live entity (to tell a rename from a drop+add, and to leave untouched attributes alone), then writes. Every ALTER ENTITY is ~4 calls even when it changes one thing.
  3. Reading a module you just wrote costs 2 calls, not 0. After any write, that module’s on-disk .mpr is stale, so SHOW/DESCRIBE of it is reconstructed live from Studio Pro (a structure read plus an enrichment read for attribute types) instead of being served locally.

What this means for performance

Every tool call is a synchronous JSON-RPC round trip to Studio Pro, executed sequentially. Wall-clock time is therefore roughly:

total ≈ (number of tool calls) × (round-trip latency)

So latency scales with the choreography-expanded call count, not the number of statements. The practical levers:

  • Batch writes into one script run, not many small invocations — the initialize handshake and schema fetches are paid once per connection.
  • Drop trailing SHOW/DESCRIBE verification from production scripts. Each read-back of a just-written module costs ~2 calls, so the verification block is an easy chunk to cut when you don’t need it.
  • The file backend has zero round trips. If Studio Pro doesn’t need to be open — e.g. CI, bulk generation, or scripted migrations — the file backend applies the same script far faster. Reserve --mcp for when live, validated, no-reload edits in an open model are the point.

What this means for token cost and LLM context

This is the part that matters most when an AI agent is driving mxcli, and it hinges on a single question: who makes the tool calls?

Agent → mxcli (efficient)

When an agent runs mxcli exec script.mdl -p app.mpr --mcp (via the shell, the REPL, or an editor command), the agent emits one action — the MDL script — and receives one summarized result. The ~60 PED tool calls, and their often verbose, schema-laden JSON responses, happen inside the mxcli process. They never enter the model’s context window, and they don’t each cost a model turn.

What lands in context:

  • the MDL script — compact and declarative (the example is ~150 readable lines), and
  • mxcli’s textual summary of the run.

That is a few thousand tokens, regardless of how many PED round trips it took.

Agent → Studio Pro MCP directly (expensive)

An agent connected straight to Studio Pro’s MCP server has no such amortization. To build the same domain model it must issue each of the ~60 calls as its own tool call:

  • every request and every response (schemas, validation output, read-backs) is written into the context window, and
  • each call is a separate model turn — so the model “thinks” ~60 times instead of once, multiplying both latency and per-turn token cost.

The same work that is one declarative script for the mxcli path becomes dozens of imperative, stateful tool calls whose intermediate results accumulate in context.

The takeaway

                     calls in LLM context   model turns   tokens in context
Agent → mxcli (--mcp)          0                  1              low (script + summary)
Agent → Studio Pro MCP        ~60               ~60             high (every request + result)

mxcli’s MCP backend is, in effect, a compression layer over the PED tool surface: the LLM works in compact, reviewable MDL while mxcli absorbs the chatty, validation-heavy, read-modify-write round trips on the model’s behalf. For bulk authoring this is the difference between one cheap turn and dozens of expensive ones — and it is why, even though a script “costs” ~60 tool calls, running it through mxcli keeps that cost out of the model’s context.

See also

Agent Dev-Loop Benchmark

An agent building an app runs mxcli hundreds of times, and waits for each run. The agent dev-loop benchmark measures that wait directly: a fixed session of mxcli commands, one process per command, timed from outside. There is no model in the loop, so the same commit gives the same session every time.

It complements two other things rather than replacing them:

  • the read and decode budget tests in the unit suite, which fail a pull request that makes a command open more unit files than a bound. They are exact, but they count reads, on a 370-unit project, for the commands someone already found slow;
  • mxcli eval run with diag session-report, which measures a whole agent session — model calls, tokens, wall time — and therefore varies from run to run.

Design and rationale: docs/11-proposals/PROPOSAL_agent_devloop_benchmark.md.

Running it

make bench                                  # time this checkout's binary
make bench BASELINE=/path/to/other/mxcli    # A/B against another build
make bench BENCH_FLAGS='-sizes small -reps 3 -out record.json'
make bench BENCH_RUNTIME=11.13.0            # also the running-app session

Without BENCH_RUNTIME it needs no mxbuild and no Docker: every session starts from a copy of the committed testdata/pedapp project. A default run with a baseline took 14 minutes on a 4-CPU machine; -sizes small -reps 1 takes under a minute and is enough to see whether a change moved anything.

To check a suspected regression, build the commit before it, keep that binary, and pass it as BASELINE:

git stash && make build && cp bin/mxcli /tmp/mxcli-before && git stash pop
make bench BASELINE=/tmp/mxcli-before

The session

The steps run in order against one project, each on the state the previous ones left. The scripts are generated (scripts/agentbench/gen) and identical on every machine.

StepCommandWhat it shows
open-cold-c "show modules", first command on the projectbuilding the unit index
openthe same command againthe floor under every command: start-up and project open
domainexec: 25 entities and their associations
microflowsexec: 50 microflows
pagesexec: 50 pages
describedescribe of one entity, microflow and page
catalogrefresh catalog
catalog-fullrefresh catalog full
querya select on the catalog
checkcheck --references of a one-microflow script
single-editexec of that scriptthe most frequent thing an agent does
catalog-after-editrefresh catalog full after one changeincremental refresh
bulk-alterexec re-creating all 50 microflows with a changed body
re-execthe same script againmust change nothing
renamerename entity, with references in microflows and pages

It runs at two project sizes. small is PedApp as committed. large is PedApp with a generated app executed into it first (the seed-* lines, run once). The session’s own scripts are the same at both sizes, so the Scaling table at the end of the output shows what each command costs more purely because the project around it is bigger.

The running-app session

BENCH_RUNTIME=<Mendix version> adds a third session: validating and building the project with mx, and what an agent waits for once the app is up. Measured sessions spend more wall time here than in every mxcli-only command together.

StepCommandsWhat it shows
mx-checkdocker checkvalidation by mx itself, on a temporary copy of the project
builddocker build --skip-checkthe full build to a deployable package
bootrun --local --watch --detach --db-type hsqldb --test-endpoint --allow-calluntil the app serves: build, runtime start, schema
page-checkplaywright checka text verdict for the home page
hot-applyexec of a one-microflow change, then run waita behavioural change, live without a restart
test-attachtest --attacha test run in the running app, nothing built
apply-entityexec adding an attribute, then run waita structural change: runtime restart and schema update
stoprun stop
test-coldtest --local --db-type hsqldba test run that boots its own runtime

It differs from the other two sessions in four ways:

  • The project is created by mxcli new, with the session’s entities, microflows and pages authored into it. PedApp cannot be built — the fixture carries no theme. new and the authoring are timed once, by the candidate (the new and author lines).
  • mxcli installs what it needs. mxbuild and the runtime for the version are downloaded when they are not cached, before anything is timed. The machine has to bring JDK 21, and node with the playwright package and a Chromium for page-check; BENCH_FLAGS=-page-check=false leaves that step out.
  • The arms take turns. A boot leaves a runtime up, and two at once would share the ports and the processor they are timed on. So one binary runs its whole session and is stopped, then the other. The pairing is by repetition, minutes apart instead of seconds.
  • Read the wall ratio only. The time is spent in mxbuild and the runtime, which are the same in both arms; the CPU column covers the mxcli process alone and says little here. A real mxcli regression is a smaller share of a noisier figure than in the other sessions — with the same code in both arms the ratios ran from 0.92 to 1.04 over three repetitions.
  • Neither mx-check nor build needs a Docker daemon, despite the command names: both run mx and MxBuild directly.

boot and stop each report 7 files changed: the test endpoint’s documents, added when the app starts and removed when it stops.

The database is the runtime’s built-in HSQLDB, so no PostgreSQL is needed. Figures are for one Mendix version and do not carry over to another.

Reading the output

With a baseline, each step has these columns:

  • new / old — the median wall time of each binary.
  • wall × — new divided by old. This is the figure to act on.
  • CPU × — the same ratio for user plus system CPU time. CPU time is less disturbed by a busy machine than wall time, and blind to waiting on the disk. A step that is slower in both columns is slower; one that is slower in wall time only is more often noise or I/O.
  • reads — unit files the command read, as counted by mxcli itself.
  • files changed — unit files added, rewritten with different bytes, or removed, measured by comparing the project before and after the command.

The last two do not depend on the machine at all. A difference between the arms is exact, and is flagged counts-changed whatever the time did.

Why a ratio and not a time

A shared CI runner varies from run to run by more than most regressions, so tonight’s absolute time cannot be compared with last night’s. The benchmark therefore runs the two binaries interleaved on the same machine, one step at a time, each on its own copy of the project, and alternates which goes first. The ratio is the median over the repetitions of each pair’s ratio, so a slow minute on the machine slows both halves of a pair and cancels.

Running the same code in both arms gives the noise floor. On the machine this page was written on (4 CPUs, 5 repetitions), most steps stayed within ±3 % and the widest were 0.90 and 1.12; the session-weighted total read × 0.93 and × 0.98. The default flag threshold is × 1.20, and a single night just beyond it is worth a second run before it is worth an investigation.

The session-weighted total

Each step’s median is multiplied by how often a real session runs that kind of command, and summed. The weights are rounded from the sessions measured in PROPOSAL_agent_loop_efficiency.md: a one-document exec, a check and a read each stand for many calls, the bulk steps for themselves. A record carries weights_version, so a change of weights is never read as a change of speed.

Flags

FlagMeaning
slower / fasterwall ratio beyond the threshold
counts-changedthe two binaries read or changed a different number of files
counts-unstableone binary’s counts differed between repetitions — a deterministic session should never show this
wrote-on-re-execre-running a settled script changed a file. This is a defect (Idempotent Writes), and the only finding that makes the run exit non-zero besides a failing command

A baseline that cannot run a step — an older build that does not know a statement the session uses — stops there. The steps before keep their ratio; it and the later ones have none, because the two projects no longer hold the same thing.

The counts on any command

The read and write counts come from an environment variable that works for any mxcli command:

MXCLI_STATS_FILE=/tmp/stats.jsonl mxcli exec bulk-alter.mdl -p app.mpr
cat /tmp/stats.jsonl
# {"unit_file_reads":73,"writes_offered":49,"writes_written":49}

One line is appended per project connection closed, so a command that reconnects, or starts child mxcli processes, appends several. writes_written counts stored units rewritten after reconciliation; a newly created document is not in it, which is why the benchmark also counts changed files from outside.

The nightly job

The bench job in .github/workflows/nightly.yml downloads the previous nightly release’s binary, runs all three sessions against it, writes the table to the job summary, and commits the JSON record to the benchmarks branch as results/YYYY/MM/<timestamp>-<sha>.json. That branch holds records only and is never merged.

The job does not gate the nightly release. It fails only when a command of a session fails or a wrote-on-re-exec is found. The running-app session runs last, and a session that fails does not take the completed ones with it: their record is still written and committed.

What it does not measure

  • A check or build after a change — mx-check and build run once, on the project as authored, before the app boots.
  • A PostgreSQL-backed app — the running-app session uses HSQLDB.
  • mxcli new as an A/B — it is timed once, by the candidate, as a trend figure.
  • Tokens and model calls — see mxcli eval run.

Capabilities Overview

Everything mxcli can do, organized by use case.

Read: Explore and understand your project

CapabilityCommandNotes
List modulesLIST MODULESWith marketplace version info
List entitiesLIST ENTITIES [IN Module]With attribute/association counts
List microflowsLIST MICROFLOWS [IN Module]With parameter/activity counts
List pagesLIST PAGES [IN Module]With widget counts
List enumerationsLIST ENUMERATIONS [IN Module]
List constantsLIST CONSTANTS [IN Module]
List workflowsLIST WORKFLOWS [IN Module]
List nanoflowsLIST NANOFLOWS [IN Module]
List layoutsLIST LAYOUTS [IN Module]
List snippetsLIST SNIPPETS [IN Module]
Compact overviewDESCRIBE STRUCTURE [DEPTH 1|2|3]Tree view of entire project
Describe any documentDESCRIBE ENTITY|MICROFLOW|PAGE ...Full MDL output (re-executable)
Full-text searchSEARCH 'keyword'Across all strings and source
Show languagesLIST LANGUAGESAll languages in the project
Describe app securityDESCRIBE APP SECURITYSecurity overview
Show access rulesLIST ACCESS ON [ENTITY] Module.NameEntity/microflow/page/nanoflow access
Show settingsLIST SETTINGSProject-level settings

Write: Create and modify documents

Domain Model

CapabilityCommandStatus
Create entityCREATE PERSISTENT ENTITYFull support
Create non-persistent entityCREATE NON-PERSISTENT ENTITYFull support
Create view entity (OQL)CREATE VIEW ENTITY ... AS SELECT10.21+
Modify entityALTER ENTITY ... ADD/MODIFY/DROP ATTRIBUTEFull support
Create associationCREATE ASSOCIATION ... FROM ... TOFull support
Create enumerationCREATE ENUMERATIONFull support
Create/modify idempotentlyCREATE OR MODIFY ENTITYFull support

Microflows and Nanoflows

CapabilityCommandStatus
Create microflowCREATE MICROFLOW ... BEGIN ... END60+ activity types
Create nanoflowCREATE NANOFLOW ... BEGIN ... ENDClient-side flows
CRUD operationsCREATE, CHANGE, COMMIT, DELETEObject manipulation
RetrieveRETRIEVE ... FROM ... WHEREDatabase/association queries
Control flowIF/THEN/ELSE, LOOP, WHILEIncluding nested
Call flowsCALL MICROFLOW, CALL NANOFLOWWith parameters
Show pageLIST PAGE Module.Page(...)With page parameters (9.7+)
REST requestsSEND REST REQUESTGET/POST/PUT/DELETE
Database queriesEXECUTE DATABASE QUERYExternal databases
Log messagesLOG INFO|WARNING|ERRORWith templates
Error handlingRAISE ERROR, error handlersOn activities
ValidationVALIDATION FEEDBACKField-level validation

Pages

CapabilityCommandStatus
Create pageCREATE PAGE ... { widgets }50+ widget types
Create snippetCREATE SNIPPETReusable widget fragments
Modify pageALTER PAGE ... SET/INSERT/DROP/REPLACEIn-place modifications
Built-in widgetsTEXTBOX, TEXTAREA, DATEPICKER, etc.All standard widgets
Layout widgetsLAYOUTGRID, CONTAINER, GROUPBOXWith responsive columns
Display widgetsDYNAMICTEXT, IMAGEIncluding pluggable Image
Data widgetsDATAVIEW, LISTVIEWWith datasource binding
Pluggable widgetsDATAGRID2, GALLERY, COMBOBOXTemplate-based
Action buttonsACTIONBUTTONSave, cancel, microflow, page
NavigationNAVIGATIONLISTWith items and actions

Security

CapabilityCommandStatus
Create module rolesGRANT ... TO RoleEntity/microflow/page access
Create user rolesvia security commandsMap to module roles
Entity access rulesGRANT READ/WRITE ON Entity TO RoleWith XPath constraints
Demo usersCREATE DEMO USERFor testing

Integration

CapabilityCommandStatus
REST clientsCREATE CONSUMED REST SERVICEConsumed REST services
OpenAPI importCREATE CONSUMED REST SERVICE (OpenAPI: '...')Generate REST client from an OpenAPI 3.x spec (Swagger 2.0 refused; responses imported as JSON, no entities yet)
Business eventsCREATE BUSINESS EVENT SERVICEEvent definitions
Database connectionsCREATE DATABASE CONNECTIONExternal SQL
OData servicesLIST CONSUMED ODATA SERVICES/SERVICESRead-only browsing
CapabilityCommandStatus
Navigation profilesALTER NAVIGATIONHome pages, menus
Project settingsALTER SETTINGSJava version, theme, etc.

Workflows

CapabilityCommandStatus
Create workflowCREATE WORKFLOWUser tasks, decisions
Parallel splitsSupportedFork/join patterns

Analyze: Code intelligence and quality

CapabilityCommandNotes
Cross-referencesLIST CALLERS/CALLEES OFWho calls what
Impact analysisLIST IMPACT OF Module.EntityWhat breaks if I change this
Transitive callersLIST CALLERS OF ... TRANSITIVEFull call chain
Lintingmxcli lint -p app.mpr19 built-in + 31 Starlark rules
Best practices reportmxcli report -p app.mprScored report with categories
Missing translationsQUAL005 linter ruleDetects incomplete translations
Catalog queriesSELECT ... FROM CATALOG.tablesSQL over project metadata
Validationmxcli check -p app.mprSyntax + optional mx check
Diffmxcli diff -p app.mpr changes.mdlCompare script vs project

Automate: CI/CD and scripting

CapabilityCommandNotes
Inline commandsmxcli -p app.mpr -c "CMD1; CMD2; CMD3"Semicolon-separated, single connection
Script filesmxcli exec script.mdl -p app.mprRun MDL files
Stdin pipingecho "CMD" | mxcli -p app.mprQuiet mode, no prompts
Docker buildmxcli docker build -p app.mprBuild MDA in container
Docker checkmxcli docker check -p app.mprValidate in container
Testingmxcli test tests/ -p app.mpr [--local] [--watch] [--attach].test.mdl / .test.md; --local needs no Docker, --watch keeps the runtime warm (~2s per run), --attach runs against an app already up
SARIF outputmxcli lint --format sarifFor CI integration
New projectmxcli new <name> --version X.Y.ZCreate project from scratch with all tooling
Init projectmxcli initSet up .claude/ with skills
Setup mxclimxcli setup mxcli [--os linux]Download platform-specific mxcli binary
Platform loginmxcli auth loginStore a Mendix PAT for marketplace access
Marketplace browsemxcli marketplace search/info/versionsDiscover content; --min-mendix compatibility filter
Marketplace downloadmxcli marketplace download <id> [--version X]Fetch a content version’s .mpk to disk
Marketplace installmxcli marketplace install <id> -p app.mprWidget → widgets/; new module → mx module-import; existing module reported, not updated
Marketplace packagemxcli marketplace package <Module> -p app.mpr [-o dir]Export a module as a .mpk via mx create-module-package (module’s own libs only; warns about other modules’ files); installs back with install --file

Known Limitations

AreaLimitationWorkaround
Design properties (Atlas v3)Requires Mendix 11.0+Use CSS classes on 10.x
REST query parametersRequires Mendix 11.0+Build query string manually on 10.x
Concurrent editingNot supported — a file-based write is refused while Studio Pro has the project open (<project>.mpr.lock present)Close Studio Pro before mxcli writes, or write through it with --mcp; exec --force overrides
Widget template driftCE0463 on version mismatchMPK augmentation handles most cases
Marketplace module updateinstall reports an existing module and does not update itmxcli marketplace diff / update <id> -p app.mpr --to <version> (preserves entity identity and role grants; local edits are refused or saved with --save-edits)
47 of 52 metamodel domainsNot yet implementedREST, OData write, etc. pending

Version Compatibility

See Version Compatibility for detailed per-version feature availability.

Customizing AI Generation

How to make AI assistants (Claude Code, OpenCode, Cursor, etc.) generate Mendix code that follows your team’s conventions.

How it works

When you run mxcli init in your Mendix project, it creates a .claude/ folder with skills – markdown files that teach AI assistants how to write MDL for your project. The AI reads these before generating code.

your-app/
  .claude/
    CLAUDE.md          # Project-level instructions
    skills/
      write-microflows.md
      create-page.md
      generate-domain-model.md
      check-syntax.md
      ...
    commands/
      mendix/
        explore.md
        create-entity.md
        ...

Quick setup

# Initialize AI context in your project
mxcli init -p your-app.mpr

# This creates .claude/ with skills, commands, and a CLAUDE.md

Commit the .claude/ folder to your repo so all team members get the same AI behavior.

Customizing CLAUDE.md

The CLAUDE.md file is the top-level instruction file that AI assistants read first. Add your team’s conventions here:

# Project Instructions

## Naming Conventions
- Entity names: PascalCase, singular (Customer, not Customers)
- Microflow names: ACT_ prefix for button actions, SUB_ for sub-microflows
- Page names: P_ prefix followed by action and entity (P_Customer_Overview)
- Attribute names: PascalCase (FirstName, not first_name)

## Architecture Rules
- All database queries go through a DAL_ (Data Access Layer) microflow
- Never commit objects inside a loop
- Use sub-microflows for logic blocks over 10 activities
- Every entity must have a Name or Description attribute for display

## Security
- Default: no access. Explicitly grant access for each role.
- Admin role gets full access
- User role gets read-only on reference data

## Module Structure
- Core: domain model, enumerations, constants
- API: REST services, consumed services
- UI: pages, snippets, layouts
- Logic: microflows, nanoflows

Writing custom skills

Skills are markdown files in .claude/skills/ that provide focused guidance for specific tasks. The AI reads the relevant skill when performing that type of work.

Example: Company-specific entity skill

Create .claude/skills/create-entity.md:

# Creating Entities

## Standard attributes
Every persistent entity MUST include:
- CreatedDate: DateTime (set by before-commit microflow)
- ModifiedDate: DateTime (set by before-commit microflow)
- CreatedBy: Association to Administration.Account
- IsDeleted: Boolean DEFAULT false (soft delete pattern)

## Example
CREATE PERSISTENT ENTITY CRM.Customer (
  Name: String(200) NOT NULL,
  Email: String(200),
  Phone: String(50),
  IsActive: Boolean DEFAULT true,
  -- Standard audit fields (required)
  CreatedDate: DateTime,
  ModifiedDate: DateTime,
  IsDeleted: Boolean DEFAULT false
);

CREATE ASSOCIATION CRM.Customer_CreatedBy
FROM CRM.Customer TO Administration.Account
TYPE Reference
OWNER Default;

## Naming rules
- Singular names: Customer, not Customers
- No abbreviations: InvoiceLine, not InvLine
- Boolean attributes: Is/Has prefix (IsActive, HasAddress)

Example: Microflow naming skill

Create .claude/skills/microflow-naming.md:

# Microflow Naming Convention

## Prefixes
| Prefix | Use | Example |
|--------|-----|---------|
| ACT_ | Button/page actions | ACT_Customer_Save |
| SUB_ | Sub-microflows (called by others) | SUB_ValidateEmail |
| SCH_ | Scheduled events | SCH_CleanupExpiredSessions |
| DS_ | Data sources for widgets | DS_Customer_GetActive |
| DAL_ | Data access layer | DAL_Customer_FindByEmail |
| VAL_ | Validation rules | VAL_Customer_CheckRequired |
| BCO_ | Before commit | BCO_Customer_SetAuditFields |
| ACO_ | After commit | ACO_Order_SendConfirmation |
| BDE_ | Before delete | BDE_Customer_CheckReferences |

## Parameters
- Always use $ prefix: $Customer, $OrderList
- Entity parameters: use entity name ($Customer, not $Cust)
- List parameters: add List suffix ($CustomerList)
- Primitive parameters: descriptive name ($SearchQuery, $MaxResults)

Example: Page patterns skill

Create .claude/skills/page-patterns.md:

# Page Patterns

## Standard layouts
- Overview pages: DataGrid2 with search, pagination, new/edit buttons
- Edit pages: Popup layout with form fields and Save/Cancel footer
- Detail pages: Full-width layout with tabs for related data

## Widget standards
- Always set Label on input widgets
- Use COMBOBOX for enums with < 10 values, RADIOBUTTONS for < 5
- DataGrid2 for lists, not ListView (unless card layout needed)
- Always add AlternativeText on IMAGE widgets

## Example overview page
CREATE PAGE CRM.P_Customer_Overview (
  Title: 'Customer Overview',
  Layout: Atlas_Core.Atlas_Default
) {
  LAYOUTGRID lgMain {
    ROW row1 {
      COLUMN col1 (DesktopWidth: 12) {
        DATAGRID2 dgCustomers (
          DataSource: DATABASE CRM.Customer
            WHERE [IsDeleted = false]
            SORT BY Name ASC
        ) {
          ...columns...
        }
      }
    }
  }
}

Using custom commands

Custom commands in .claude/commands/ let users invoke common workflows with a slash command. Create .claude/commands/new-entity.md:

Create a new entity with standard audit fields in module $ARGUMENTS:

1. Create the entity with Name, standard audit fields, and soft delete
2. Create the CreatedBy association
3. Create the BCO_ before-commit microflow that sets audit fields
4. Grant Admin full access, User read access
5. Validate with mxcli check

Then invoke with: /new-entity CRM.Product

Enforcing conventions with linting

Custom Starlark linter rules in .claude/lint-rules/ can automatically check conventions:

# .claude/lint-rules/naming_prefix.star
RULE_ID = "CUSTOM001"
RULE_NAME = "Microflow Naming Prefix"
DESCRIPTION = "Microflows must use standard naming prefixes"
CATEGORY = "convention"
SEVERITY = "warning"

VALID_PREFIXES = ["ACT_", "SUB_", "SCH_", "DS_", "DAL_", "VAL_", "BCO_", "ACO_", "BDE_"]

def check():
    violations = []
    for mf in microflows():
        name = mf.name.split(".")[-1]  # Get name without module
        has_prefix = False
        for prefix in VALID_PREFIXES:
            if name.startswith(prefix):
                has_prefix = True
                break
        if not has_prefix:
            violations.append(violation(
                message="Microflow '{}' does not use a standard prefix ({})".format(
                    mf.qualified_name, ", ".join(VALID_PREFIXES)),
                location=location(module=mf.module_name, document_type="microflow",
                                  document_name=mf.name)
            ))
    return violations

Run with:

mxcli lint -p your-app.mpr

Best practices

  1. Commit .claude/ to git – everyone on the team gets the same AI behavior
  2. Start small – add one skill at a time, test it, then add more
  3. Use examples – one good example teaches more than paragraphs of rules
  4. Run mxcli lint – automated enforcement is better than hoping the AI remembers
  5. Update skills when conventions change – outdated skills cause inconsistency
  6. Use mxcli check after generation – always validate AI output before committing

Pluggable Widgets Across Mendix Versions

Modern Mendix UI is built from pluggable widgets — DataGrid 2, Combo box, Gallery, the data-grid filters, and any widget you install from the Marketplace. mxcli can create and configure these widgets in a page, and it does so in a way that stays correct as the installed widget versions change from project to project. This guide explains how that works and how to inspect what mxcli has discovered about a widget.

The problem: “the definition of this widget has changed” (CE0463)

Every pluggable-widget instance stored in an .mpr embeds a copy of the widget’s definition — the full list of properties, their types, default values, captions, categories, and declared order. When you open the app, Studio Pro (and mx check) compares that embedded definition against the widget package (.mpk) actually installed in the project. If they differ in any way, you get:

CE0463 — “The definition of this widget has changed. Update this widget by right-clicking it and selecting ‘Update widget’…”

Different Mendix versions — and different Marketplace releases of the same widget — ship slightly different definitions: a property is added, an enumeration option is renamed, a category moves, the property order changes. A tool that emitted one fixed definition would trip CE0463 the moment the project’s widget version didn’t match.

How mxcli stays version-correct

mxcli never hard-codes one widget shape. It reconciles a known-good template against the widget package installed in your project, so the definition it writes matches the version you actually have at the moment you create the widget.

  1. Embedded template. mxcli ships a known-good template for each built-in widget (extracted from Studio Pro). This provides the correct nested BSON structure that is hard to build from scratch. A widget with no embedded template — anything from the Marketplace, and the Charts family — is instead generated whole from the package (GenerateFromMPK), so an embedded template is not a prerequisite for support.

  2. Reconcile against the project .mpk. When you pass a project (-p), mxcli finds the widget’s .mpk in the project’s widgets/ folder, parses its definition, and updates the template to match: it adds and removes properties, rewrites enumeration option sets, fixes categories/captions/defaults, fills in per-property metadata, and — importantly — re-orders the properties to the package’s declared order, including the system properties (Label / Visibility / Editability) at their real position. This is exactly what Studio Pro’s own “Update widget” does, derived generically from the package with no widget-specific code.

  3. Apply the widget’s own dynamic rules. Which properties a widget shows depends on its configuration — a Combo box in enumeration mode hides the association properties; a DataGrid 2 with selection off hides the selection labels. That logic lives in each widget’s compiled editorConfig.js. mxcli statically extracts those dynamic property rules and applies them, so a freshly-created widget carries exactly the defaults Studio Pro would give it.

    A property the widget hides must hold its default, or the build fails with CE0463. So a hidden property you leave out is written at its default; one you set to its default draws an MDL-WIDGET10 warning (the value is ignored); and one you set to anything else is an MDL-WIDGET10 error, which exec refuses before writing. describe leaves out a hidden property that holds its default. For example, a Timeline grouped by 'day' hides groupByMonthOptions, and one with customVisualization: true hides both format enumerations.

    This only covers rules mxcli recognises. mxcli widget describe <name> -p app.mpr ends with “N of M editor hide-rules recognized”. A rule it cannot read hides nothing as far as check knows, so a value set behind one reaches mx check unwarned.

The result: widgets created by mxcli open cleanly across Mendix 10.x and 11.x with the widget packages bundled with those releases — verified on Mendix 11.12 with Data Widgets 3.4: a project authored entirely by mxcli reports 0 CE0463.

Two things this does not cover

Upgrading a widget package does not update widgets you already created. A package that drops a property leaves every stored instance carrying a property the new definition no longer has — which is what CE0463 reports, and what its message (“Update all widgets”) asks you to fix. This is normal Mendix behaviour and it is not specific to mxcli: Studio Pro’s own widgets are flagged identically. Measured on Mendix 11.12, upgrading Data Widgets 3.4 → 3.11.3:

Projectas authoredafter the upgradeafter mx update-widgets
a real mxcli-built app0 errors36 CE0463 (7 mxcli-authored, 29 Studio Pro’s own)0 errors

Apply that update with mxcli fix widgets -p app.mpr: it runs Mendix’s own mx update-widgets and writes the result back in the MPR v2 layout (see Repairing the model). Do not run bare mx update-widgets on an MPR v2 project — it collapses mprcontents/ back to the single-file v1 layout. Studio Pro’s “Update all widgets” does the same job. mxcli widget sync reconciles schemas in mxcli’s own code and is partial today (see below).

Re-running a page’s description does not upgrade its widgets either. A create or modify (or create or replace) of a stored page, snippet or layout keeps each pluggable widget as Studio Pro stored it, schema included — as Studio Pro itself does. A widget the statement leaves unchanged is kept whole; one it changes keeps every property the statement does not change, so editing a column caption leaves the other columns, the row click and the grid’s data source as they were. (A widget whose data context the statement changes is rebuilt from mxcli’s template instead, since its stored bindings no longer apply.) So a widget stays at its stored package version until you update it with mxcli fix widgets.

This changed during the beta. Earlier builds rebuilt every pluggable widget of a rewritten page from the installed .mpk, so re-running describe output also upgraded it — and usually cleared its CE0463 — while silently resetting whatever the statement could not say (a specialized column binding, a row click). If a script relied on that side effect to clear CE0463, run mxcli fix widgets instead. mxcli docker check checks the project as stored and labels each CE0463 a widget resync would clear as a stale widget definition, so it shows which ones mxcli fix widgets fixes (--update-widgets checks a resynced copy only, and hides them).

Two widgets do not yet author cleanly against Data Widgets 3.10+. Freshly created gallery and dropdownfilter widgets produce CE0463 on those packages; datagrid2, textfilter, datefilter and numberfilter are clean. Tracked as mendixlabs/mxcli#716. On the bundled 3.4 package all of them are clean.

For the internal mechanics (template extraction, BSON cross-references, the augment pipeline), see Widget Template System and Pluggable Widget Engine.

Inspecting the discovered widget format

Two commands let you see exactly what mxcli knows about the widgets available to a project.

List available widgets

mxcli widget list                 # built-in widget definitions
mxcli widget list -p app.mpr      # also loads project-specific definitions

Describe one widget

mxcli widget describe shows a widget’s expected properties and its dynamic property rules. Name the widget by its MDL keyword (COMBOBOX, DATAGRID2, GALLERY, …) or its full widget id.

# mxcli's built-in view of the widget:
mxcli widget describe COMBOBOX

# the version-accurate view from the package installed in the project:
mxcli widget describe COMBOBOX -p app.mpr

# machine-readable:
mxcli widget describe DATAGRID2 -p app.mpr --format json

With -p, the properties and rules come from the widget package actually installed in the project (widgets/*.mpk) — the version-accurate “discovered” format, and the only way to inspect a Marketplace widget mxcli has no built-in knowledge of. Without -p, they come from mxcli’s embedded template.

Example (abridged):

Widget: Combo box (combobox)
  ID:      com.mendix.widget.web.combobox.Combobox
  Version: 2.5.0
  Kind:    pluggable
  Source:  project .mpk

Properties (58):
  source                 enumeration required default=context {context|database|static}  (General::Data source)
  optionsSourceType      enumeration required default=association {association|enumeration|boolean}  (General::Data source)
  attributeEnumeration   attribute required  (General::Data source)
  …
  selectAllButtonCaption textTemplate required  (General::Multiple-selection (reference set))
  Label                  system  [system]
  Visibility             system  [system]
  Editability            system  [system]
  customEditability      enumeration required default=default {default|never|conditionally}  (General::Editability)
  …

Dynamic property rules (10):
  itemSelectionMethod       hidden when itemSelection = "None"
  keepSelection             hidden when itemSelection ≠ "Multi"
  loadMoreButtonCaption     hidden when pagination ≠ "loadMore"
  …
  — 7 of 23 editor hide-rules recognized

The property list is in the widget’s declared order (system properties appear where the package declares them, not appended at the end), each with its type, whether it’s required, its default, enumeration options, and category. Object-list widgets (e.g. DataGrid 2 columns) show their nested item properties indented. The dynamic rules read as “property X is hidden when Y”, and the trailing coverage line reports how many of the widget’s editor hide-rules mxcli recognized.

--format json

Both the properties and the rules are available as JSON for scripting:

{
  "widgetId": "com.mendix.widget.web.combobox.Combobox",
  "version": "2.5.0",
  "source": "project .mpk",
  "properties": [
    { "key": "source", "type": "enumeration", "required": true, "default": "context",
      "enum": ["context", "database", "static"], "category": "General::Data source" }
  ],
  "dynamicRules": [
    { "property": "loadMoreButtonCaption", "hiddenWhen": "pagination ≠ \"loadMore\"" }
  ]
}

Reconciling stored widgets after a package upgrade (mxcli widget sync)

Everything above keeps a widget correct as it is authored. When you later upgrade the widget package, the instances already stored in your pages go stale and Mendix reports CE0463 on each one. Studio Pro fixes this with “Update all widgets”; mxbuild has mx update-widgets, which works but destroys mprcontents/ on an MPR v2 project, collapsing it to a single-file v1 layout. mxcli fix widgets runs it and restores the v2 layout, and is the complete fix.

mxcli widget sync is the alternative that does the reconciliation in mxcli’s own code:

mxcli widget sync -p app.mpr --dry-run     # preview; names every property
mxcli widget sync -p app.mpr               # apply

It reconciles schema — properties the package added, dropped or redefined — and never changes a property value you set. It skips any widget whose .mpk is not installed rather than guessing.

This is partial today. On the reference fixture (Data Widgets 3.4 → 3.11.3) it clears 7 of 40 CE0463 errors where mx update-widgets clears all 40. The widget type it writes is byte-identical to Mendix’s own output; a value-level difference that has not yet been identified keeps DataGrid2 and Gallery instances erroring. Preview with --dry-run and confirm with mx check before relying on it. It runs on either engine — the modelsdk and legacy backends produce structurally identical output.

Each unit is verified for duplicate GUIDs before being written, and the run aborts rather than writing one. This matters because Mendix validates GUID uniqueness only when saving: a duplicate loads fine and passes mx check, then breaks the next save — and mx update-widgets collapses mprcontents/ before it discovers the problem, leaving the project both flattened and unloadable.

Marketplace and custom widgets

mxcli widget describe -p app.mpr <widget-id> works for any widget installed in the project, including Marketplace and custom widgets mxcli has no embedded template for — it reads the definition and editor rules straight from the installed .mpk. To teach the page builder to author a custom widget from MDL, extract a definition for it first:

mxcli widget extract --mpk widgets/MyWidget.mpk    # writes a .def.json
mxcli widget init -p app.mpr                        # extract defs for all project widgets

A widget’s MDL keyword is the last segment of its ID (com.mendix.widget.web.combobox.Combobox → COMBOBOX). When two installed widgets derive the same keyword, the pluggable one keeps it and the other is named with its vendor in front (itvisors.keyboardshortcut.KeyboardShortcut → ITVISORSKEYBOARDSHORTCUT), or with CUSTOM in front for a legacy custom widget (KeyboardShortcut.widget.KeyboardShortcut → CUSTOMKEYBOARDSHORTCUT). mxcli widget list shows which name each widget got.

refresh catalog refreshes these definitions too, but skips that step when no .mpk or .def.json file has changed since the last refresh by the same mxcli build.

See Marketplace Content for downloading and installing packages.

Marketplace Content

How to discover, download, and install Mendix Marketplace content (modules, widgets) from the command line with mxcli marketplace.

Authenticate first

Marketplace access needs a Mendix Personal Access Token (PAT). Create one at user-settings.mendix.com (Developer Settings → Personal Access Tokens), then:

mxcli auth login                 # prompts for the PAT
# or, non-interactively (CI):
mxcli auth login --token <PAT>
# or via the environment:
export MENDIX_PAT=<PAT>

mxcli auth status                # confirm it validates

Credentials are stored at ~/.mxcli/auth.json (mode 0600).

Discover content

# search by name/publisher
mxcli marketplace search "database connector"

# show one item's details (by content id)
mxcli marketplace info 2888

# list available versions, optionally filtered by Mendix compatibility
mxcli marketplace versions 2888
mxcli marketplace versions 2888 --min-mendix 10.24.0

Each item has a numeric content id (shown by search/info); you pass it to download and install.

Search caching. The marketplace Content API has no server-side search, so the first search fetches the full catalog listing and caches it under ~/.mxcli/marketplace-catalog-<profile>.json for a day. After that, searches (for any keyword) are served from the cache instantly. Pass --refresh to bypass the cache and re-fetch (e.g. to pick up a brand-new module).

Download a .mpk

# latest version, into the current directory under its CDN filename
mxcli marketplace download 2888

# a specific version, to a chosen path
mxcli marketplace download 2888 --version 7.0.2 -o ./mods/dbc.mpk

The download is atomic (written to a temp file and renamed), so a cancelled run never leaves a truncated .mpk.

Install into a project

install downloads the content and places it according to its type:

mxcli marketplace install 20 -p app.mpr               # a widget
mxcli marketplace install 2888 --version 7.0.3 -p app.mpr   # a module
Content typeWhat install does
WidgetCopies the .mpk into the project’s widgets/ folder (overwrites on update). Reload in Studio Pro or run mx update-widgets to pick it up.
Module (new)Copies the module in with mxcli’s own writer, preserving the project’s MPR format, plus everything the package ships (widgets, themesource, …). Requires a matching mxbuild — run mxcli setup mxbuild -p app.mpr if missing.
Module (already present)Reported, not modified — see below.
Theme / Starter App / SampleDownloaded to disk with import instructions (import via Studio Pro).

Installing a .mpk you already have (--file)

Not every package comes from the marketplace: an internal or company-standard module handed over as a file, a theme module, a package fetched earlier with download, or a CI job with no token. --file installs such a package through the same writer as an online install, with no marketplace lookup and no PAT:

mxcli marketplace install --file ./CompanyTheme.mpk -p app.mpr

The kind is read from the package’s own package.xml — a module goes through the transplant writer (storage format preserved, theme modules included), a widget lands in widgets/. Anything else is refused rather than guessed at. A module installed this way carries no marketplace version stamp, because it has none: update and diff will report that no marketplace content is installed under it, which is the truth.

The reference project the writer builds is still created with mx at the project’s Mendix version, so a package that mx itself cannot open — one exported from a Studio Pro further back than the version window mx module-import accepts — is refused with mx’s own message. Convert it once with mx convert on a toolset whose window covers it, then --file the result.

The latest version is often not installable

New releases are published against the newest Studio Pro patch within days of it shipping, and install with no --version resolves to the latest — so on a project that is not on the very newest patch, the default is routinely the one version that cannot be imported. Measured on an 11.12.1 project: the latest release of all six agent-editor stack modules required 11.12.2, published five days earlier.

install and update check the version’s published minimum before downloading anything, and name the version to use instead:

Agent Commons 4.2.0 requires Mendix 11.12.2, and the project is 11.12.1
  hint: install --version 4.1.0 (the newest release built for 11.12.1 or older)

mxcli marketplace versions <id> shows the same information as a MIN MENDIX column.

Dependencies are not resolved

install installs exactly the content you name. Its dependencies are neither fetched nor named — read the check errors after each install, which identify what is missing by qualified name. The error count is not monotonic, because each new module brings its own unmet dependencies: installing the agent-editor stack into a vanilla 11.12.1 app went 0 → 15 → 0 → 18 → 1 → 22 → 1 → 1. Dependencies include widget content as well as modules (CE0462 "Could not find widget ...").

Exporting a module (marketplace package)

The reverse of install --file: write a module of a project out as a .mpk, for a company-standard module, a CI artifact, or to move a module to another app.

mxcli marketplace package MyModule -p app.mpr                 # ./MyModule.mpk
mxcli marketplace package MyModule -p app.mpr -o ./dist --json

It wraps Mendix’s own mx create-module-package at the project’s Mendix version, so the package is the one Studio Pro’s Export module package makes. The module name is checked against the project first (a typo lists the modules instead of failing with mx exit code 3), and mx’s exit codes 1–4 are reported with what they mean and what mx printed. Whatever mx prints on a successful export goes to stderr, so --json output stays clean.

The mx must be the project’s Mendix version. mx is found from an mx on PATH first, then a Studio Pro install, then the mxbuild cache, falling back to any cached one when the project’s version is missing, so the first match can belong to another Mendix version than the app. mxcli reads the version of the mx it found (from the mx.deps.json beside it, else its version-named folder), compares major.minor.patch with the project’s, and refuses a mismatch — or an mx whose version cannot be told — naming both versions. The fix is mxcli setup mxbuild --version <version> (take an older mx off PATH, which is used before it). --mxbuild-path is the deliberate override: with it a mismatch only warns on stderr and the export goes on. A path that does not exist is an error.

FlagPurpose
-p, --projectThe project holding the module (required).
-o, --output-dirWhere <Module>.mpk goes (default: the current directory; created if missing).
--all-libsBundle every userlib/ JAR. By default only the module’s own required libs go in (mx -l): 12 KB against 24 MB on an app with Business Events installed.
--exclude <regex>Leave out files whose path inside the package matches. mx takes one expression, so join alternatives with |; write the folder separator as ..
--exclude-managed-dependenciesLeave out managed dependencies (mx -m).
--mxbuild-pathTake mx from this Mendix installation (or mxbuild) instead of resolving it by the project’s version. Overrides the version check: a mismatch warns instead of refusing.
--jsonPrint {path, sizeBytes, files, foreignEntries}.

Files of other modules. -l filters only userlib/ JARs. The app’s whole resources/ folder is still exported (measured on 11.12.5: another module’s resources/EventLog/ came along; its javasource/ and themesource/ did not). After the export mxcli opens the package and warns on stderr about every resources/<X>/…, javasource/<x>/… or themesource/<x>/… entry where X is not the exported module (the last two are a defensive check), and prints the pattern that drops them:

warning: the package holds 1 file(s) that belong to other modules, not to MyModule:
  resources/EventLog/x.txt
To leave them out, export again with:
  --exclude '^resources.EventLog.'

The pattern is quoted for a POSIX shell (a ' in your own --exclude is written '''), so it can be pasted as it stands; in PowerShell or cmd quote it for that shell.

The pattern uses . for the separator because a character class or a backslash ([/\]) makes mx print its usage and write nothing. . matches any character, so a pattern that would also match a file of the module itself is not suggested.

The project is not changed. mx create-module-package writes into the project it is given: measured on a blank 11.12.5 app, one run rewrote theme-cache/web/theme.compiled.css and its map, added deployment/sass/main.scss, and created the module’s themesource/<module>/ folder; the model files were untouched. mxcli therefore runs it on a temporary copy, as docker check does, and a before/after listing of the project (sizes and modification times) was identical. An output directory inside the project (-o ./dist) is left out of that copy, so earlier packages are not copied along.

The package installs back with marketplace install --file; the round trip into a fresh 11.12.5 app checks with 0 errors.

Why installs do not use mx module-import

mx module-import rewrites an MPR v2 project as v1. Measured on a blank Mendix 11.12.1 app, a single import turned a 69 KB .mpr plus 341 .mxunit files into one 14 MB SQLite blob with no mprcontents/ — and the same was observed independently on 11.13.0, so it is not version-specific:

before   .mpr     69,632 bytes  +  341 .mxunit   tables: Unit, _MetaData, _Transaction
after    .mpr 14,295,040 bytes  +    0 .mxunit   tables: Unit, _MetaData

That is not cosmetic. The v2 layout is what makes the model diffable and mergeable per document: it is what mxcli diff-local reads, and what makes an idempotent re-run observable as “no files changed”. The conversion is one-way — mx convert targets Mendix versions, not storage formats.

So install copies the module’s units directly instead, which keeps the project in whatever format it already uses and also works for theme modules (module-import refuses those outright). Measured: CommunityCommons 11.5.1 into a vanilla 11.12.1 app — 128 units and 126 bundled files, mprcontents/ grew from 369 to 497 .mxunit files, and mx check reports 0 errors.

--allow-format-change selects the legacy module-import path, which still refuses to run silently:

refusing to import: app.mpr uses the MPR v2 storage format, and 'mx module-import'
would rewrite it as v1.
...
  - Import the module in Studio Pro, which preserves the format; or
  - pass --allow-format-change to accept the conversion to MPR v1.

If you take that route the command states plainly that the project is now v1.

Updating an existing module

Updating a module that is already in the project is not done by install. install detects it, reports the installed and target versions, names the command that does the update, and stops:

Module "DatabaseConnector" is already installed (version 7.0.1).
Target: version 7.0.3.
It is not updated by install (an update can discard local edits). To update it,
review what would change, then apply it with entity identity and role grants preserved:
  mxcli marketplace diff 2888 -p app.mpr --to 7.0.3
  mxcli marketplace update 2888 -p app.mpr --to 7.0.3

Before you update, the question worth answering is whether anyone has edited the module since it was installed — because the update will not ask. That is what marketplace diff is for; see below.

Two reasons make automatic in-place module updates unsafe:

  1. Local edits. Teams sometimes modify a marketplace module after importing it; a blind re-import would discard those changes.
  2. Persistent-entity IDs. A fresh import assigns new entity $IDs. The runtime database keys data by entity ID, so re-importing a module with persistent entities would make the runtime treat them as different entities — losing data.

Studio Pro’s Marketplace Update performs an ID-preserving merge that the mx CLI does not expose; marketplace update (below) is mxcli’s equivalent, as a separate command so that install never updates implicitly.

Updating a module (marketplace update)

update replaces an installed module with another published version, preserving the two things a plain replace destroys.

# Refuses if you have edited the module, naming what it would discard
mxcli marketplace update 23513 -p app.mpr --to 4.5.0

# Park those edits as re-executable MDL, then update over them
mxcli marketplace update 23513 -p app.mpr --to 4.5.0 --save-edits ./local-edits
mxcli marketplace update 23513 -p app.mpr --to 4.5.0 --force
mxcli exec ./local-edits/entity-Account.mdl -p app.mpr
Administration updated 4.3.2 → 4.5.0
  28 units copied, 9 element identities preserved, 2 role grant(s) restored.

  Removed in 4.5.0 (1) — their database columns or tables will go on the next deploy:
    Account/MyLocalEdit

What it preserves, and why

  • Element identity. The runtime keys entities and their attributes on the model’s GUID, so a module whose documents are replaced without carrying the old GUIDs is a different module to the database and its tables are dropped on the next deploy. Studio Pro transplants them; so does this.
  • Access. A user role’s grant of a module role lives in the project’s security document, not the module, so removing the module takes the grants with it and putting it back does not return them.
  • Everything else the package ships. A module is not only its model: the .mpk carries widget binaries under widgets/, styling and design-property declarations under themesource/, and whatever else it needs. All of it is replaced: every file the package’s package.xml lists under <files>, which is exactly what mx module-import installs. What it does not list is the package’s own metadata — License.txt, manifest.json, the READMEOSS report, Mendix EULA.docx — and is left out, named on one line of the output. DataWidgets 3.11.3 replaces 49 such files, and skipping them leaves the app running old widget code and reporting CE6083 for design properties the module itself declares.

It does not use mx module-import, which would rewrite an MPR v2 project as v1 and refuses theme modules. Units are copied with mxcli’s own writer, so the project keeps its format.

Local edits

Local edits are not preserved. update refuses when it finds any, --save-edits writes them out first, and --force proceeds. Two limits on the saved files:

  • They are the element’s resulting state, not a diff, so replaying restores additions and changes but not removals.
  • An element that could not be described has nothing to save, and is reported rather than skipped.

Afterwards

mxcli fix widgets -p app.mpr
mxcli fix design-properties -p app.mpr
mxcli docker check -p app.mpr
mxcli diff-local -p app.mpr

A headless install or update leaves two repairs for Mendix’s own tools: CE0463 (the project’s stored widget instances are older than the widget packages beside them) and CE6087 (a module references design properties an older Atlas spelled differently). Both are expected, not faults in the install. See mxcli fix below.

A third repair is not left to you: CE0066 (“Entity access is out of date”). A transplant copies the incoming module’s units in verbatim, so a package whose access rules do not cover every member of their entities would land that error in your project with nothing said about it (mendixlabs/mxcli#1085). Install and update now reconcile the module’s rules for themselves and report the count:

  3 entity access rule(s) reconciled — the package's rules did not cover every
  member of their entities, which Mendix reports as CE0066.

Nothing is written when the rules already match, so a well-formed package comes through untouched. When the line does appear, the module’s domain model differs from the package — the same change Studio Pro’s Update security button makes — and mxcli marketplace diff will read it as a local edit. To run the repair by hand, or on a project updated by an older mxcli, use UPDATE SECURITY:

mxcli -p app.mpr -c "update security UserCommons"

Measured: Administration 4.3.2 → 4.5.0 and DataWidgets 3.5.0 → 3.11.3 both reach 0 errors afterwards.

Bundled widgets and install order

A module package carries a copy of every widget its pages use, pinned at the module author’s release time — and different modules pin different versions of the same widget. Atlas_Web_Content 4.3.0 ships five Data Widgets at 3.4.0 that DataWidgets 3.11.3 ships at 3.11.3.

install and update keep the newer copy and say so:

  Kept 5 newer widget(s) the package would have rolled back:
    widgets/com.mendix.widget.web.Datagrid.mpk — kept 3.11.3, package ships 3.4.0

Without this, module install order silently decided which widget versions the project ended up with, and nothing reported it — an out-of-date widget is not a check error. A package that ships the same widget both as a .mpk and as an unpacked tree (FeedbackModule 5.0.0) installs only the .mpk.

When the installed version is no longer published

diff and update both download the installed version to establish the local-edit baseline, so both fail when it has been unpublished — as NanoflowCommons 6.0.0 has, while a blank 11.13 app still ships it. --force does not help: it overrides a finding, and there is no finding to override. mxcli marketplace update … --no-baseline accepts that the question cannot be answered and updates anyway, discarding any local edits to that module without naming them.

Why diff and update are slow, and what is cached

Both commands answer their question by building a reference project: a blank Mendix app of the consuming project’s version with the published module imported into it, so the comparison runs against a real project rather than against a .mpk. That costs a download plus two mx invocations, and update builds two references — the installed version, to answer “has anyone edited this?”, and the version being moved to.

Two caches under ~/.mxcli/marketplace-refs/ keep that off the clock:

CacheKeyed byHoldsSaves
blank/Mendix versiona whole blank app (~37 MB)the mx create-project in every reference build
ref/published version UUID + Mendix versionthe model only (~14 MB)the whole reference, on a repeat

Measured on Administration (content 23513, 4.3.2 → 4.5.0, Mendix 11.12.1):

mxcli marketplace diff 23513 -p app.mpr --to 4.5.0

  no cache      66s
  cold cache    47s     (blank app built once, reused by the second reference)
  warm           9s

A ref/ entry stores the .mpr (and mprcontents/) and nothing else. A reference project is a whole blank Mendix app, but only its model is ever read — widgets/, themesource/, theme-cache/ and javascriptsource/ account for 20 MB of 34 MB and are never opened, because an update takes the module’s bundled files from the .mpk rather than from the reference. Storing the model alone made entries 58% smaller and warm runs faster, since less is copied.

The Mendix version is part of both keys, because a reference built at a different version reports Mendix’s own conversions as local edits. An entry is only used once its completion marker is present and the project’s version stamp has been re-checked on the way out, so a half-written or mislabelled entry is rebuilt rather than trusted.

ref/ keeps the 12 most recently used entries and evicts the rest — twelve is what a six-module update sweep builds, so a whole sweep stays cached at ~170 MB. It is bounded at all because running out of disk part way through an update is worse than rebuilding an entry: update does not roll back, so a failed write leaves the module already dropped. blank/ is not bounded — one entry per Mendix version.

MXCLI_REF_CACHE_MAX=20 mxcli marketplace diff …   # keep more (0 = keep everything)
MXCLI_NO_REF_CACHE=1   mxcli marketplace diff …   # build everything from scratch

Reach for MXCLI_NO_REF_CACHE=1 before deleting anything: it answers “is this a stale-cache problem?” while leaving the evidence in place.

Note that --force still builds the baseline. It is not wasted work — under --force the command prints the locally changed elements it is about to replace, and that list is the whole point of the check. Use --no-baseline when you genuinely want to skip it.

Where references are built: the 260-character limit

mx refuses to write any file whose path is 260 characters or longer — on every platform, not only Windows: the check is in mx itself. A reference project holds the blank template plus the imported package, and their deepest files are bundled node_modules trees: 181 characters for the 11.12.1 blank app, about 202 for NanoflowCommons 7.x. That leaves under 60 characters for the directory they are built in, and the default Windows %TEMP% (C:\Users\<user>\AppData\Local\Temp) used most of it, so diff and update failed with “The specified file name or path is too long”.

References are therefore built under the shortest directory the user owns: %USERPROFILE%\.mxcli\t on Windows, /tmp on Linux and macOS, or TMP / TMPDIR when that is shorter still. It is never a folder at a drive root such as C:\mxt, which every local account may modify; point TMP there yourself if you accept that. If a package is deep enough to hit the limit anyway, the error names the directory, its length, and what is left for the package.

Repairing the model (mxcli fix)

mx update-widgets and mx rename-design-properties each fix something only Mendix can fix, and each rewrites an MPR v2 project into the single-file v1 format while doing it. Measured on 11.12.1: update-widgets took 369 .mxunit files to 0 and a 69,632-byte index to 14,405,632 bytes; rename-design-properties took 1,865 files to 0 and a 249,856-byte index to 39,895,040 bytes, having renamed 149 design properties across 41 documents. The conversion is one-way.

mxcli fix widgets -p app.mpr             # CE0463
mxcli fix design-properties -p app.mpr   # CE6087

marketplace install runs both itself after a module install (a widget install runs the widget resync only) when mx for the project’s version is available, and falls back to printing these two commands with a warning when it is not.

Updated design properties: 42 unit(s) changed.
  Storage: 1868 .mxunit file(s), unchanged from 1868 before (MPR v2 preserved).

Each runs the same Mendix tool, reads every unit back out of the converted file, restores the v2 storage, and writes the changed units into it through mxcli’s own writer. The storage count is printed before and after because that is where the failure this exists to prevent would show up — as a zero.

Measured end to end on a vanilla 11.12.1 app carrying the agent-editor stack: mx check reported 203 errors (202 × CE0463 + 1 × CE6087), and 0 after the two commands, with the project still MPR v2 (1,868 .mxunit files) — reproduced from a restored pre-fix snapshot.

Re-running is free: a second run reports 0 units changed, because idempotent writes elide a unit whose content did not really change. An MPR v1 project is passed straight through, since these tools write v1 natively.

CommandPersists?Use
mxcli fix widgetsyesthe fix — after any headless install
mxcli fix design-propertiesyesthe fix — after any headless install
mxcli docker checknochecks the project as stored, so it reports the CE0463 a headless install leaves, and labels each one the widget resync clears as a stale widget definition (the resync runs on a temporary copy only)
mxcli widget syncyes, partialreconciles widget schemas in mxcli’s own code; clears 7 of 40 on the reference fixture

CE6087 is distinct from CE6083, which is a missing design-property declaration and is fixed by installing everything the package ships — something install and update already do.

update does not roll back. Work on a copy or have the project in version control.

Has this module been edited? (marketplace diff)

Studio Pro’s Marketplace Update replaces the module and discards local edits without asking. marketplace diff answers the question that decides whether that is safe:

# What have I changed in this module since installing it?
mxcli marketplace diff 23513 -p app.mpr
Administration — installed 4.3.2 (Mendix 11.12.1)

  Locally modified (1 of 21 elements):
    changed   ENTITY Account

An untouched module reports how much was actually checked, not just a verdict:

  No local modifications: 21 of 21 elements verified unchanged.

What it does

  1. Reads which marketplace version each module in the project records. The project stores the marketplace version UUID per module, so the module and the exact release it came from are both identified without guessing — the listing name does not help here (content 23513 is listed as “Administration module” and installs a module called Administration; “Data Widgets” installs DataWidgets).
  2. Downloads that version’s .mpk and imports it into a throwaway reference project built at the project’s own Mendix version, so the package goes through the same conversion the installed copy did.
  3. Describes every element of the module on both sides and compares the descriptions.

Comparison is on DESCRIBE output rather than raw storage: an untouched module differs from its own published package in thousands of BSON paths, because the installed copy carries subtrees the package does not.

Requires the mxbuild toolchain for the project’s Mendix version — mxcli setup mxbuild -p app.mpr. Building the reference at a different version is refused rather than warned about, because Mendix’s own conversions would then show up as your edits.

Theme modules

mx module-import refuses a theme module outright (“Importing theme module is not supported”), which would take Atlas_Core, Atlas_Web_Content and Conversational UI off the table. The refusal is gated on a single flag on the module document inside the package, so diff clears it on its own throwaway copy before importing — the published package and your project are untouched.

Atlas modules are among the most-edited in real projects, so this matters more than the module count suggests:

Atlas_Web_Content — installed 4.1.0 (Mendix 11.12.1)

  No local modifications found, but 46 of 89 elements could not be read —
  this is not a clean bill of health.

  Not comparable (46) — reported as unknown, never as unchanged:
    unknown   PAGE_TEMPLATE Blank (no DESCRIBE support for PAGE_TEMPLATE)
    ...

The 46 are page templates, which have no DESCRIBE handler yet. They are reported rather than quietly counted as unchanged — see CATALOG.PAGE_TEMPLATES.

What an upgrade would touch

--to adds the other half of the question: what the module’s author changed, and whether it collides with what you changed.

mxcli marketplace diff 23513 -p app.mpr --to 4.5.0
  Upgrading to 4.5.0 would touch 5 element(s), 1 of which you have modified:
    CONFLICT  ENTITY Account

  Studio Pro's update would discard those local edits without asking.

In CI

--json emits the machine-readable form, for a build gate that fails when a marketplace module has been edited:

mxcli marketplace diff 23513 -p app.mpr --json
{
  "module": "Administration",
  "installedVersion": "4.3.2",
  "mendixVersion": "11.12.1",
  "locallyModified": true,
  "verified": true,
  "modified": ["ENTITY Account"],
  "unchangedCount": 20
}

Read both locallyModified and verified. verified: false means at least one element could not be described, so “no modifications found” is not a conclusion you can act on — an element that cannot be read is reported as unknown, never as unchanged:

  No local modifications found, but 1 of 21 elements could not be read —
  this is not a clean bill of health.

Flags

FlagPurpose
-p, --projectThe project holding the installed module (required).
--to <version>Also report what upgrading to this version would touch, and which of those you have modified.
--module <name>Name the module explicitly, when the project records no marketplace version for it (a hand-imported copy) or several modules match.
--jsonEmit JSON instead of text.

MDL Quick Reference

Complete syntax reference for MDL (Mendix Definition Language). This is the authoritative reference for all MDL statement syntax.

Entity Generalization (EXTENDS)

CRITICAL: EXTENDS goes BEFORE the opening parenthesis, not after!

-- Correct: EXTENDS before (
CREATE PERSISTENT ENTITY Module.ProductPhoto EXTENDS System.Image (
  PhotoCaption: String(200)
);

-- Wrong: EXTENDS after ) = parse error!
CREATE PERSISTENT ENTITY Module.Photo (
  PhotoCaption: String(200)
) EXTENDS System.Image;

Domain Model

StatementSyntaxNotes
Create entityCREATE [OR MODIFY] PERSISTENT|NON-PERSISTENT ENTITY Module.Name (attrs);Persistent is default
Create with extendsCREATE PERSISTENT ENTITY Module.Name EXTENDS Parent.Entity (attrs);EXTENDS before (
Create view entityCREATE VIEW ENTITY Module.Name (attrs) AS SELECT ...;OQL-backed; no storage, edit-in-memory, write back via source
Create external entityCREATE EXTERNAL ENTITY Module.Name FROM CONSUMED ODATA SERVICE Module.Client (...) (attrs);From consumed OData
Create external entitiesCREATE [OR MODIFY] EXTERNAL ENTITIES FROM Module.Client [INTO Module] [ENTITIES (...)];Bulk from $metadata
Drop entityDROP ENTITY Module.Name;
Describe entityDESCRIBE ENTITY Module.Name;Full MDL output
List entitiesLIST ENTITIES [IN Module];List all or filter by module
Create enumerationCREATE [OR MODIFY] ENUMERATION Module.Name (Value1 'Caption', ...);
Drop enumerationDROP ENUMERATION Module.Name;
Create associationCREATE ASSOCIATION Module.Name FROM Parent TO Child TYPE Reference|ReferenceSet [OWNER Default|Both] [ON DELETE CASCADE|RESTRICT|SET NULL];
Drop associationDROP ASSOCIATION Module.Name;

ALTER ENTITY

Modifies an existing entity without full replacement.

OperationSyntaxNotes
Add attributeALTER ENTITY Module.Name ADD ATTRIBUTE Attr: Type [constraints];One action per statement
Drop attributeALTER ENTITY Module.Name DROP ATTRIBUTE AttrName;
Modify attributeALTER ENTITY Module.Name MODIFY ATTRIBUTE Attr: NewType [constraints];Change type/constraints
Rename attributeALTER ENTITY Module.Name RENAME ATTRIBUTE OldName TO NewName;Also rewrites stored references and XPath constraints; microflow expressions are not rewritten
Add indexALTER ENTITY Module.Name ADD INDEX [IdxName] (Col1 [ASC|DESC], ...);Name optional
Drop indexALTER ENTITY Module.Name DROP INDEX IdxName;By index name
Set documentationALTER ENTITY Module.Name SET DOCUMENTATION 'text';
Set positionALTER ENTITY Module.Name SET POSITION (100, 200);Canvas position

Example:

mdl 1;
ALTER ENTITY Sales.Customer ADD ATTRIBUTE Phone: String(50);
ALTER ENTITY Sales.Customer ADD ATTRIBUTE Notes: String(unlimited);

ALTER ENTITY Sales.Customer RENAME ATTRIBUTE Phone TO PhoneNumber;

ALTER ENTITY Sales.Customer ADD INDEX (Email);

Constants

StatementSyntaxNotes
List constantsLIST CONSTANTS [IN Module];List all or filter by module
Describe constantDESCRIBE CONSTANT Module.Name;Full MDL output
Create constantCREATE [OR MODIFY] CONSTANT Module.Name [FOLDER 'path'] ( Type: DataType, DefaultValue: 'value' [, ExposedToClient: true] );The clause form TYPE … DEFAULT … warns MDL-DEPR136
Drop constantDROP CONSTANT Module.Name;

Example:

mdl 1;
CREATE CONSTANT MyModule.ApiBaseUrl ( Type: String, DefaultValue: 'https://api.example.com' );
CREATE CONSTANT MyModule.MaxRetries ( Type: Integer, DefaultValue: 3 );
CREATE CONSTANT MyModule.EnableLogging ( Type: Boolean, DefaultValue: true );

OData Clients, Services & External Entities

StatementSyntaxNotes
List consumed odata servicesLIST CONSUMED ODATA SERVICES [IN Module];Consumed OData services
Describe consumed odata serviceDESCRIBE CONSUMED ODATA SERVICE Module.Name;Full MDL output
Create consumed odata serviceCREATE [OR MODIFY] CONSUMED ODATA SERVICE Module.Name (...);Version, MetadataUrl, Timeout, etc.
Alter consumed odata serviceALTER CONSUMED ODATA SERVICE Module.Name SET (Key: Value, ...);
Drop consumed odata serviceDROP CONSUMED ODATA SERVICE Module.Name;
List published odata servicesLIST PUBLISHED ODATA SERVICES [IN Module];Published OData services
Describe published odata serviceDESCRIBE PUBLISHED ODATA SERVICE Module.Name;Full MDL output
Create published odata serviceCREATE [OR MODIFY] PUBLISHED ODATA SERVICE Module.Name (...) AUTHENTICATION ... { PUBLISH ENTITY ... };
Alter published odata serviceALTER PUBLISHED ODATA SERVICE Module.Name SET (Key: Value, ...);
Drop published odata serviceDROP PUBLISHED ODATA SERVICE Module.Name;
List external entitiesLIST EXTERNAL ENTITIES [IN Module];OData-backed entities
List external actionsLIST EXTERNAL ACTIONS [IN Module];Actions used in microflows
Create external entityCREATE [OR MODIFY] EXTERNAL ENTITY Module.Name FROM CONSUMED ODATA SERVICE Module.Client (...) (attrs);
Create external entitiesCREATE [OR MODIFY] EXTERNAL ENTITIES FROM Module.Client [INTO Module] [ENTITIES (...)];Bulk from $metadata
Grant OData accessGRANT ACCESS ON PUBLISHED ODATA SERVICE Module.Name TO Module.Role, ...;
Revoke OData accessREVOKE ACCESS ON PUBLISHED ODATA SERVICE Module.Name FROM Module.Role, ...;
List contract entitiesLIST CONTRACT ENTITIES FROM Module.Client;Browse cached $metadata
List contract actionsLIST CONTRACT ACTIONS FROM Module.Client;Browse cached $metadata
Describe contract entityDESCRIBE CONTRACT ENTITY Module.Client.Entity [FORMAT mdl];Properties, types, keys
Describe contract actionDESCRIBE CONTRACT ACTION Module.Client.Action [FORMAT mdl];Parameters, return type
List contract channelsLIST CONTRACT CHANNELS FROM Module.Service;Browse cached AsyncAPI
List contract messagesLIST CONTRACT MESSAGES FROM Module.Service;Browse cached AsyncAPI
Describe contract messageDESCRIBE CONTRACT MESSAGE Module.Service.Message;Message payload properties
Query contract entitiesSELECT * FROM CATALOG.CONTRACT_ENTITIES;Requires REFRESH CATALOG
Query contract actionsSELECT * FROM CATALOG.CONTRACT_ACTIONS;Requires REFRESH CATALOG
Query contract messagesSELECT * FROM CATALOG.CONTRACT_MESSAGES;Requires REFRESH CATALOG

OData Client Example:

CREATE CONSUMED ODATA SERVICE MyModule.ExternalAPI (
  Version: '1.0',
  ODataVersion: OData4,
  MetadataUrl: 'https://api.example.com/odata/v4/$metadata',
  Timeout: 300
);

OData Service Example:

CREATE PUBLISHED ODATA SERVICE MyModule.CustomerAPI (
  Path: 'odata/customers/',
  Version: '1.0.0',
  ODataVersion: OData4,
  Namespace: 'MyModule.Customers',
  Authentication: (basic, session)
)
{
  PUBLISH ENTITY MyModule.Customer AS 'Customers' (
    ReadMode: SOURCE,
    InsertMode: SOURCE,
    UpdateMode: NOT_SUPPORTED,
    DeleteMode: NOT_SUPPORTED,
    UsePaging: Yes,
    PageSize: 100
  )
  -- The KEY attribute must be unique on the entity
  -- (Email: String(200) UNIQUE), or mxbuild reports CE6624.
  EXPOSE (Email (KEY), Name, Phone);
};

Microflows - Supported Statements

StatementSyntaxNotes
Variable declarationDECLARE $Var Type = value;Primitives: String, Integer, Boolean, Decimal, DateTime
Entity declarationDECLARE $Entity Module.Entity;No AS keyword, no = empty
List declarationDECLARE $List List of Module.Entity = empty;
AssignmentSET $Var = expression;Variable must be declared first
Create object$Var = CREATE Module.Entity (Attr = value) [COMMIT [WITHOUT EVENTS]] [REFRESH];
Change objectCHANGE $Entity (Attr = value) [COMMIT [WITHOUT EVENTS]] [REFRESH];
CommitCOMMIT $Entity [WITHOUT EVENTS] [REFRESH];Omitted = with events, matching Studio Pro
DeleteDELETE $Entity [REFRESH];
RollbackROLLBACK $Entity [REFRESH];Reverts uncommitted changes
Retrieve (DB)RETRIEVE $Var FROM Module.Entity [WHERE condition];Database XPath retrieve
Retrieve (Assoc)RETRIEVE $List FROM $Parent/Module.AssocName;Retrieve by association
Call microflow$Result = CALL MICROFLOW Module.Name (Param = $value);
Call nanoflow$Result = CALL NANOFLOW Module.Name (Param = $value);
Show pageSHOW PAGE Module.PageName (Param = $value);($Param = …) and (Param: …) are deprecated
Close pageCLOSE PAGE [n];n pages, default 1
ValidationVALIDATION FEEDBACK $Entity/Attribute MESSAGE 'message';Requires attribute path + MESSAGE
LogLOG INFO|WARNING|ERROR [NODE 'name'] 'message';
Log with stack traceLOG ERROR 'message' [WITH ({1} = $v)] WITH STACKTRACE;“Include latest stack trace”
Position@position(x, y)Canvas position (before activity)
Start event@start(x, y)Canvas position of the start, on the first statement. Omit it and the start is placed one spacing unit left of the first activity and MOVES with it on a rewrite; a start that is not at that derived spot is treated as hand-placed, survives a rewrite, and is emitted by DESCRIBE (#951)
Caption@caption 'text'Custom caption (before activity)
Color@color GreenBackground color (before activity)
Annotation@annotation 'text'Visual note attached to next activity
IFIF condition THEN ... [ELSE ...] END IF;
LOOPLOOP $Item IN $List BEGIN ... END LOOP;FOR EACH over list
WHILEWHILE condition BEGIN ... END WHILE;Condition-based loop
ReturnRETURN $value;Required at end of every flow path
Execute DB query$Result = EXECUTE DATABASE QUERY Module.Conn.Query;3-part name; supports DYNAMIC, params, CONNECTION override
Error handling... ON ERROR CONTINUE|ROLLBACK|[WITHOUT ROLLBACK] BEGIN handler END ERROR;Not supported on EXECUTE DATABASE QUERY

Microflows - NOT Supported (Will Cause Parse Errors)

UnsupportedUse InsteadNotes
CASE ... WHEN 'String' ... ELSE ...Bare enum values, one branch per valueCASE itself IS supported for enum splits; what fails is quoted/qualified values, an ELSE branch (MDL008), and an AS alias
TRY ... CATCH ... END TRYON ERROR BEGIN ... END ERROR blocksUse error handlers on specific activities

Notes:

  • RETRIEVE ... FIRST returns a single entity; RETRIEVE ... LIMIT n [OFFSET n] returns a list. A bare LIMIT 1 is a list of one.
  • ROLLBACK $Entity [REFRESH]; IS supported. Rolls back uncommitted changes to an object.

Project Organization

StatementSyntaxNotes
Microflow folderFOLDER 'path' (before BEGIN)CREATE MICROFLOW ... FOLDER 'ACT' BEGIN ... END;
Page folderFOLDER 'path' (after the name)CREATE PAGE ... FOLDER 'Pages/Detail' (...) { ... }
Move to folderMOVE <doctype> Module.Name TO FOLDER 'path';Folders created automatically. Any top-level doctype, spelled as DESCRIBE spells it
Move a mapping / structureMOVE IMPORT MAPPING|EXPORT MAPPING|JSON STRUCTURE Module.Name TO FOLDER 'path';
Place while creatingCREATE <doctype> Module.Name FOLDER 'path' ...Every doctype: a clause after the name (after the signature on microflows/nanoflows, before BEGIN). The Folder: 'path' property on pages, snippets and REST/OData services is a deprecated alias (MDL-DEPR105)
Place an existing documentCREATE OR MODIFY ... FOLDER 'path' ...Moves it; omitting the clause leaves placement alone
Move to module rootMOVE PAGE Module.Name TO Module;Removes from folder
Move across modulesMOVE PAGE Old.Name TO NewModule;Breaks by-name references – use LIST IMPACT OF first
Move to folder in other moduleMOVE PAGE Old.Name TO FOLDER 'path' IN NewModule;
Move entity to moduleMOVE ENTITY Old.Name TO NewModule;Entities don’t support folders

Nested folders use / separator: 'Parent/Child/Grandchild'. Missing folders are auto-created.

Security Management

StatementSyntaxNotes
Describe app securityDESCRIBE APP SECURITY;Displays security level, admin, demo users
List module rolesLIST MODULE ROLES [IN Module];All roles or filtered by module
List user rolesLIST USER ROLES;Project-level user roles
List demo usersLIST DEMO USERS;Configured demo users
List access on elementLIST ACCESS ON [ENTITY|MICROFLOW|PAGE|NANOFLOW] Mod.Name;Which roles can access; a bare name means the entity
Describe security matrixDESCRIBE SECURITY MATRIX [IN Module];Full access overview
Create module roleCREATE MODULE ROLE Mod.Role [DESCRIPTION 'text'];
Drop module roleDROP MODULE ROLE Mod.Role;
Create user roleCREATE USER ROLE Name ( ModuleRoles: (Mod.Role, ...), Description: '...', ManageAllRoles: true, CheckSecurity: true );Aggregates module roles; every property optional
Alter user roleALTER USER ROLE Name ADD|DROP MODULE ROLES (Mod.Role, ...);
Drop user roleDROP USER ROLE [IF EXISTS] Name;IF EXISTS makes a cleanup script re-runnable
Grant microflow accessGRANT EXECUTE ON MICROFLOW Mod.MF TO Mod.Role, ...;
Revoke microflow accessREVOKE EXECUTE ON MICROFLOW Mod.MF FROM Mod.Role, ...;
Grant page accessGRANT VIEW ON PAGE Mod.Page TO Mod.Role, ...;
Revoke page accessREVOKE VIEW ON PAGE Mod.Page FROM Mod.Role, ...;
Grant entity accessGRANT CREATE, DELETE, READ *, WRITE * ON ENTITY Mod.Entity TO Mod.Role;Supports member lists and WHERE
Revoke entity accessREVOKE ALL ON ENTITY Mod.Entity FROM Mod.Role;
Set security levelALTER APP SECURITY ( SecurityLevel: OFF|PROTOTYPE|PRODUCTION );
Toggle demo usersALTER APP SECURITY ( EnableDemoUsers: TRUE|FALSE );
Toggle guest accessALTER APP SECURITY ( EnableGuestAccess: TRUE, GuestUserRole: UserRole ); / ( EnableGuestAccess: FALSE )Anonymous users; role required (CE0133)
Rename admin userALTER APP SECURITY ( AdminUserName: 'appadmin' );Default MxAdmin
Set admin user roleALTER APP SECURITY ( AdminUserRole: AppAdmin );Must exist; DROP USER ROLE refuses the administrator’s role, so move it first
Set admin passwordALTER APP SECURITY ( AdminPassword: 'N3w-Admin-Passw0rd' );Resolves CE0129 “Administrator password has not been set”; '' is refused
Create demo userCREATE DEMO USER 'name' ( Password: 'pass', [Entity: Module.Entity,] UserRoles: (UserRole, ...) );The clause form PASSWORD … (…) warns MDL-DEPR137
Drop demo userDROP DEMO USER [IF EXISTS] 'name';IF EXISTS makes a cleanup script re-runnable

Workflows

StatementSyntaxNotes
List workflowsLIST WORKFLOWS [IN Module];List all or filter by module
Describe workflowDESCRIBE WORKFLOW Module.Name;Full MDL output
Create workflowCREATE [OR MODIFY] WORKFLOW Module.Name PARAMETER $Ctx: Module.Entity BEGIN ... END WORKFLOW;See activity types below
Drop workflowDROP WORKFLOW Module.Name;
Alter workflowALTER WORKFLOW Module.Name { SET (Key: value, ...) [ON <activity>]; INSERT BEFORE|AFTER <activity> { ... } INSERT INTO <activity> { OUTCOMES ... | PATH { ... } | BOUNDARY EVENT ... } REPLACE <activity> WITH { ... } DROP <activity> [OUTCOME '<x>' | PATH <n> | BOUNDARY EVENT]; };An activity is its name or 'caption', @n for one of several matches; the old per-action form warns MDL-DEPR140–149

Workflow Activity Types:

  • USER TASK <name> '<caption>' [PAGE Mod.Page] [TARGETING MICROFLOW Mod.MF] [OUTCOMES '<out>' { } ...];
  • CALL MICROFLOW Mod.MF [CAPTION '<text>'] [OUTCOMES '<out>' { } ...];
  • CALL WORKFLOW Mod.WF [CAPTION '<text>'];
  • DECISION ['<caption>'] OUTCOMES '<out>' { } ...;
  • PARALLEL SPLIT PATH 1 { } PATH 2 { };
  • JUMP TO <activity-name>;
  • WAIT FOR TIMER ['<expr>'];
  • WAIT FOR NOTIFICATION;
  • END;

Example:

CREATE WORKFLOW Module.ApprovalFlow
  PARAMETER $Context: Module.Request
  OVERVIEW PAGE Module.WorkflowOverview
BEGIN
  USER TASK ReviewTask 'Review the request'
    PAGE Module.ReviewPage
    OUTCOMES 'Approve' { } 'Reject' { };
END WORKFLOW;

Project Structure

StatementSyntaxNotes
Structure overviewDESCRIBE STRUCTURE;Depth 2 (elements with signatures), user modules only
Module countsDESCRIBE STRUCTURE DEPTH 1;One line per module with element counts
Full typesDESCRIBE STRUCTURE DEPTH 3;Typed attributes, named parameters
Filter by moduleDESCRIBE STRUCTURE IN ModuleName;Single module only
Include all modulesDESCRIBE STRUCTURE DEPTH 1 ALL;Include system/marketplace modules
StatementSyntaxNotes
List navigationLIST NAVIGATION;Summary of all profiles
List menu treeLIST NAVIGATION MENU [Profile];Menu tree for profile or all
List home pagesLIST NAVIGATION HOMES;Home page assignments across profiles
Describe navigationDESCRIBE NAVIGATION [Profile];Full MDL output (round-trippable)
Create/replace navigationCREATE OR REPLACE NAVIGATION Profile ...;Sets the stated clauses; an omitted clause keeps its stored value, a { } menu replaces the whole menu

Navigation Example:

CREATE OR MODIFY NAVIGATION Responsive
  HOME PAGE MyModule.Home_Web
  HOME PAGE MyModule.AdminHome FOR Administrator
  LOGIN PAGE Administration.Login
  NOT FOUND PAGE MyModule.Custom404
  {
    MENU ITEM 'Home' ( OnClick: SHOW PAGE MyModule.Home_Web )
    MENU 'Admin' {
      MENU ITEM 'Users' ( OnClick: SHOW PAGE Administration.Account_Overview )
    }
  };

Project Settings

StatementSyntaxNotes
Describe settingsDESCRIBE SETTINGS;All settings parts, as MDL
Describe settingsDESCRIBE SETTINGS;Full MDL output (round-trippable)
Alter runtime settingsALTER SETTINGS RUNTIME (Key: Value, ...);AfterStartupMicroflow, HashAlgorithm, JavaVersion, etc.
Alter configurationALTER SETTINGS CONFIGURATION 'Name' (Key: Value, ...);DatabaseType, DatabaseUrl, HttpPortNumber, etc.
Alter constantALTER SETTINGS CONSTANT @Module.Name VALUE 'val' IN CONFIGURATION 'cfg';Override constant per configuration
Alter languageALTER SETTINGS LANGUAGE (Key: Value);DefaultLanguageCode
Alter workflowsALTER SETTINGS WORKFLOWS (Key: Value, ...);UserEntity, DefaultTaskParallelism

Business Events

StatementSyntaxNotes
List servicesLIST BUSINESS EVENTS;List all business event services
List in moduleLIST BUSINESS EVENTS IN Module;Filter by module
Describe serviceDESCRIBE BUSINESS EVENT SERVICE Module.Name;Full MDL output
Create serviceCREATE BUSINESS EVENT SERVICE Module.Name (...) { MESSAGE ... };See help topic for full syntax
Drop serviceDROP BUSINESS EVENT SERVICE Module.Name;Delete a service

Java Actions

StatementSyntaxNotes
List Java actionsLIST JAVA ACTIONS [IN Module];List all or filtered by module
Describe Java actionDESCRIBE JAVA ACTION Module.Name;Full MDL output with signature
Create Java actionCREATE JAVA ACTION Module.Name(params) RETURNS type AS $$ ... $$;Inline Java code
Create with type paramsCREATE JAVA ACTION Module.Name(EntityType: ENTITY <pEntity>, Obj: pEntity) ...;Generic type parameters
Describe a parameter, name the return valueCREATE JAVA ACTION Module.Name(/** The text. */ Input: String) RETURNS String AS $Result AS $$ ... $$;/** … */ before a parameter is its description; AS $Name the return value’s name
Create exposed action... EXPOSED AS 'Caption' IN 'Category' AS $$ ... $$;Toolbox-visible in Studio Pro
Drop Java actionDROP JAVA ACTION Module.Name;Delete a Java action
Call from microflow$Result = CALL JAVA ACTION Module.Name(Param = value);Inside BEGIN…END

Parameter Types: String, Integer, Long, Decimal, Boolean, DateTime, Module.Entity, List of Module.Entity, ENUM Module.EnumName, Enumeration(Module.EnumName), StringTemplate(Sql), StringTemplate(Oql), ENTITY <pEntity> (type parameter declaration), bare pEntity (type parameter reference).

Type Parameters allow generic entity handling. ENTITY <pEntity> declares the type parameter inline and becomes the entity type selector; bare pEntity parameters receive entity instances:

CREATE JAVA ACTION Module.Validate(
  EntityType: ENTITY <pEntity> NOT NULL,
  InputObject: pEntity NOT NULL
) RETURNS Boolean
EXPOSED AS 'Validate Entity' IN 'Validation'
AS $$
return InputObject != null;
$$;

Pages

MDL uses explicit property declarations for pages:

ElementSyntaxExample
Page properties(Key: value, ...)(Title: 'Edit', Layout: Atlas_Core.Atlas_Default)
Page variablesVariables: ( $name: Type = <expr> )Variables: ( $show: Boolean = true )
Widget nameRequired after typeTEXTBOX txtName (...)
Attribute bindingAttribute: AttrNameTEXTBOX txt (Label: 'Name', Attribute: Name)
Variable bindingDataSource: $VarDATAVIEW dv (DataSource: $Product) { ... }
Action bindingAction: TYPEACTIONBUTTON btn (Caption: 'Save', Action: save changes)
Microflow actionAction: MICROFLOW Name(Param = val)Action: MICROFLOW Mod.ACT_Process(Order = $Order)
Database sourceDataSource: DATABASE EntityDATAGRID dg (DataSource: DATABASE Module.Entity)
Selection bindingDataSource: SELECTION widgetDATAVIEW dv (DataSource: SELECTION galleryList)
CSS classClass: 'classes'CONTAINER c (Class: 'card mx-spacing-top-large')
Inline styleStyle: 'css'CONTAINER c (Style: 'padding: 16px;')
Dynamic classesDynamicClasses: 'expr'CONTAINER c (DynamicClasses: if $currentObject/IsActive then 'is-active' else '') — runtime-computed; stacks on Class
Design propertiesDesignProperties: (...)CONTAINER c (DesignProperties: ('Spacing top': 'Large', 'Full width': ON))
Width (pixels)Width: integerIMAGE img (Width: 200)
Height (pixels)Height: integerIMAGE img (Height: 150)
Page sizePageSize: integerDATAGRID dg (PageSize: 25)
Pagination modePagination: modeDATAGRID dg (Pagination: virtualScrolling)
Paging positionPagingPosition: posDATAGRID dg (PagingPosition: both)
Paging buttonsShowPagingButtons: modeDATAGRID dg (ShowPagingButtons: auto)

DataGrid Column Properties:

PropertyValuesDefaultExample
Attributeattribute name(required)Attribute: Price
Captionstringattribute nameCaption: 'Unit Price'
Alignmentleft, center, rightleftAlignment: right
WrapTexttrue, falsefalseWrapText: true
Sortabletrue, falsetrue/falseSortable: false
Resizabletrue, falsetrueResizable: false
Draggabletrue, falsetrueDraggable: false
Hidableyes, hidden, noyesHidable: no
ColumnWidthautoFill, autoFit, manualautoFillColumnWidth: manual
Sizeinteger — a relative weight, not px1Size: 2 — under ColumnWidth: manual only, rendered 2fr against 1fr per autoFill column
minWidthauto, minContent, manualautominWidth: manual — under ColumnWidth: autoFit only
minWidthLimitinteger (px)100minWidthLimit: 220 — the minimum in pixels, under ColumnWidth: autoFit, minWidth: manual
VisibleexpressiontrueVisible: $showColumn — evaluated once for the grid: a page variable or parameter, never $currentObject (MDL-WIDGET43, CE0117)
DynamicCellClassexpression(empty)DynamicCellClass: if $currentObject/Stock < 10 then 'text-danger' else ''
Tooltiptext string(empty)Tooltip: 'Price in USD'

Page Example:

CREATE PAGE MyModule.Customer_Edit
(
  Params: ( $Customer: MyModule.Customer ),
  Title: 'Edit Customer',
  Layout: Atlas_Core.PopupLayout
)
{
  DATAVIEW dvCustomer (DataSource: $Customer) {
    TEXTBOX txtName (Label: 'Name', Attribute: Name)
    TEXTBOX txtEmail (Label: 'Email', Attribute: Email)
    COMBOBOX cbStatus (Label: 'Status', Attribute: Status)

    FOOTER {
      ACTIONBUTTON btnSave (Caption: 'Save', Action: SAVE CHANGES, ButtonStyle: Primary)
      ACTIONBUTTON btnCancel (Caption: 'Cancel', Action: CANCEL CHANGES)
    }
  }
};

Supported Widgets:

  • Layout: LAYOUTGRID, ROW, COLUMN, CONTAINER, CUSTOMCONTAINER
  • Input: TEXTBOX, TEXTAREA, CHECKBOX, RADIOBUTTONS, DATEPICKER, COMBOBOX
    • COMBOBOX modes: enumeration (Attribute: EnumAttr); association (Attribute: Assoc, DataSource: DATABASE Mod.Target, CaptionAttribute: Name); database (optionsSourceDatabaseDataSource: <source>, Attribute: TargetAttr, CaptionAttribute: Name, optionsSourceDatabaseValueAttribute: ValueAttr) — the datasource’s key selects database mode, the generic DataSource: association mode. Custom content (several attributes per option) is a body block of the mode: { optionssourceassociationcustomcontent { … } } in association mode, { optionssourcedatabasecustomcontent { … } } in database mode, its widgets bound to the option entity
  • Display: DYNAMICTEXT, DATAGRID, GALLERY, LISTVIEW, IMAGE, STATICIMAGE, DYNAMICIMAGE
  • Actions: ACTIONBUTTON, LINKBUTTON, NAVIGATIONLIST
  • Structure: DATAVIEW, HEADER, FOOTER, CONTROLBAR, SNIPPETCALL

ALTER PAGE / ALTER SNIPPET

Modify an existing page or snippet’s widget tree in-place without full CREATE OR REPLACE. Works directly on the raw BSON tree, preserving unsupported widget types.

OperationSyntaxNotes
Set propertySET (Caption: 'New') ON widgetNameSingle property on a widget
Set multipleSET (Caption = 'Save', ButtonStyle = Success) ON btnMultiple properties at once
Page-level setSET (Title: 'New Title')No ON clause for page properties
Insert afterINSERT AFTER widgetName { widgets }Add widgets after target
Insert beforeINSERT BEFORE widgetName { widgets }Add widgets before target
Insert intoINSERT INTO containerName { widgets }Append as the container’s last child (fills an empty container)
Drop widgetsDROP name1, name2Remove widgets by name
Replace widgetREPLACE widgetName WITH { widgets }Replace widget subtree
Pluggable propSET ('showLabel': false) ON cbStatusQuoted name for pluggable widgets
Add variableADD Variables $name: Type = <expr>Add a page variable
Drop variableDROP Variables $nameRemove a page variable
Add parameterADD Parameters $name: TypeAdd a page/snippet parameter (entity or primitive; snippet: entity only). A page with a Url needs a {name} segment — SET (Url: …) in the same statement
Drop parameterDROP Parameters $nameRemove a parameter; refused while the page still uses it

Supported SET properties: Caption, Label, ButtonStyle, Class, Style, Editable, Visible, Name, and quoted pluggable widget properties. Page-level (no ON clause, case-sensitive): Title, Class, Style, PopupWidth, PopupHeight, PopupResizable.

Example:

mdl 1;
ALTER PAGE Module.EditPage {
  SET (Caption: 'Save & Close', ButtonStyle: Success) ON btnSave;
  DROP txtUnused;
  INSERT AFTER txtEmail {
    TEXTBOX txtPhone (Label: 'Phone', Attribute: Phone)
  }
};

ALTER SNIPPET Module.NavMenu {
  SET (Caption: 'Dashboard') ON btnHome
};

Tip: Run DESCRIBE PAGE Module.PageName first to see widget names.

External SQL Statements

Direct SQL query execution against external databases (PostgreSQL, Oracle, SQL Server). Credentials are isolated – DSN never appears in session output or logs.

StatementSyntaxNotes
ConnectSQL CONNECT <driver> '<dsn>' AS <alias>;Drivers: postgres, oracle, sqlserver
DisconnectSQL DISCONNECT <alias>;Closes connection
List connectionsSQL CONNECTIONS;Shows alias + driver only (no DSN)
Show tablesSQL <alias> SHOW TABLES;Lists user tables
Show viewsSQL <alias> SHOW VIEWS;Lists user views
Show functionsSQL <alias> SHOW FUNCTIONS;Lists functions and procedures
Describe tableSQL <alias> DESCRIBE <table>;Shows columns, types, nullability
QuerySQL <alias> <any-sql>;Raw SQL passthrough
ImportIMPORT FROM <alias> QUERY '<sql>' INTO Module.Entity MAP (...) [LINK (...)] [BATCH n] [LIMIT n];Insert external data into Mendix app DB
Generate connectorSQL <alias> GENERATE CONNECTOR INTO <module> [TABLES (...)] [VIEWS (...)] [EXEC];Generate Database Connector MDL from schema
-- Connect to PostgreSQL
SQL CONNECT postgres 'postgres://user:pass@localhost:5432/mydb' AS source;

-- Explore schema
SQL source SHOW TABLES;
SQL source DESCRIBE users;

-- Query data
SQL source SELECT * FROM users WHERE active = true LIMIT 10;

-- Import external data into Mendix app database
IMPORT FROM source QUERY 'SELECT name, email FROM employees'
  INTO HRModule.Employee
  MAP (name AS Name, email AS Email);

-- Import with association linking
IMPORT FROM source QUERY 'SELECT name, dept_name FROM employees'
  INTO HR.Employee
  MAP (name AS Name)
  LINK (dept_name TO Employee_Department ON Name);

-- Generate Database Connector from schema
SQL source GENERATE CONNECTOR INTO HRModule;
SQL source GENERATE CONNECTOR INTO HRModule TABLES (employees, departments) EXEC;

-- Manage connections
SQL CONNECTIONS;
SQL DISCONNECT source;

CLI subcommand: mxcli sql --driver postgres --dsn '...' "SELECT 1" (see mxcli syntax sql). Supported drivers: postgres (pg, postgresql), oracle (ora), sqlserver (mssql).

StatementSyntaxNotes
Refresh catalogREFRESH CATALOG;Rebuild basic metadata tables
Refresh with refsREFRESH CATALOG FULL;Include cross-references and source
List catalog tablesLIST CATALOG TABLES;List available queryable tables
Query catalogSELECT ... FROM CATALOG.<table> [WHERE ...];SQL against project metadata
List callersLIST CALLERS OF Module.Name;What calls this element
List calleesLIST CALLEES OF Module.Name;What this element calls
List referencesLIST REFERENCES OF Module.Name;All references to/from
List impactLIST IMPACT OF Module.Name;Impact analysis
Describe contextDESCRIBE CONTEXT OF Module.Name;Surrounding context
Full-text searchSEARCH '<keyword>';Search across all strings and source

Cross-reference commands require REFRESH CATALOG FULL to populate reference data.

Connection & Session

StatementSyntaxNotes
ConnectCONNECT LOCAL '/path/to/app.mpr';Open a Mendix project
DisconnectDISCONNECT;Close current project
StatusSTATUS;Show connection info
RefreshREFRESH;Reload project from disk
CommitCOMMIT [MESSAGE 'text'];Save changes to MPR
Set variableSET key = value;Session variable (e.g., output_format = 'json')
ExitEXIT;Close REPL session

CLI Commands

CommandSyntaxNotes
Interactive REPLmxcliInteractive MDL shell
Inline commandsmxcli -p app.mpr -c "CMD1; CMD2; CMD3"Semicolons batch multiple commands
Inline, keep goingmxcli -p app.mpr -c "CMD1; CMD2" --continue-on-errorWithout it a batch stops at the first failing statement and says how many were not run; an empty -c is an error
Execute scriptmxcli exec script.mdl -p app.mprScript file
Stdin pipingecho "CMD" | mxcli -p app.mprQuiet mode, pipe-friendly
Check syntaxmxcli check script.mdlParse-only validation
Check referencesmxcli check script.mdl -p app.mpr --referencesWith reference validation
Lint projectmxcli lint -p app.mpr [--format json|sarif]19 built-in + 31 Starlark rules
Reportmxcli report -p app.mpr [--format markdown|json|html] [--modules A,B]Best practices report (--modules scores only those modules)
Testmxcli test tests/ -p app.mpr.test.mdl / .test.md files
Diff scriptmxcli diff -p app.mpr changes.mdlCompare script vs project
Diff localmxcli diff-local -p app.mpr --ref HEADGit diff for MPR v2
Diff revisionsmxcli diff-local -p app.mpr --ref main..featureCompare two git revisions
OQLmxcli oql -p app.mpr "SELECT ..."Query running Mendix runtime
External SQLmxcli sql --driver postgres --dsn '...' "SELECT 1"Direct database query
Docker buildmxcli docker build -p app.mprBuild with PAD patching
Docker checkmxcli docker check -p app.mprValidate with mx check
Versionmxcli version (or --version)Build version and build time
Diagnosticsmxcli diag [--bundle]Session logs, version info
Loop reportmxcli diag loop-report [--json]Which mxcli commands a session actually ran, how often, how long
Session reportmxcli diag session-report <transcript.jsonl>... [--top N] [--json]Where an agent session’s tool calls and tokens went, from its Claude Code transcript
Eval runmxcli eval run <test.md> --version X.Y.ZRun Claude Code headless on an eval test, check the project, report the session
New projectmxcli new <name> --version X.Y.ZCreate project from scratch with all tooling
Init projectmxcli init /path/to/projectAdd AI tooling to existing project
Setup mxclimxcli setup mxcli [--os linux]Download platform-specific mxcli binary
LSP servermxcli lsp --stdioLanguage server for VS Code

Data Type Mapping Table

Comprehensive mapping between MDL data types, Mendix internal types, and backend representations.

Primitive Types

MDL TypeDescriptionRange/Limits
StringVariable-length text (default length 200)1 to unlimited characters
String(n)Variable-length text with explicit length1 to unlimited characters
Integer32-bit signed integer-2,147,483,648 to 2,147,483,647
Long64-bit signed integer-9,223,372,036,854,775,808 to 9,223,372,036,854,775,807
DecimalHigh-precision decimal numberUp to 20 digits total
BooleanTrue/false valuetrue or false (must have DEFAULT)
DateTimeDate and time with timezone awarenessUTC timestamp
DateDate only (no time component)Internally stored as DateTime
AutoNumberAuto-incrementing integerTypically combined with NOT NULL UNIQUE
BinaryBinary data (files, images)Configurable max size
HashedStringSecurely hashed string (passwords)One-way hash, cannot be retrieved

Complex Types

MDL TypeDescriptionExample
Enumeration(Module.EnumName)Reference to an enumeration typeStatus: Enumeration(Sales.OrderStatus)

MDL to Backend Type Mapping

MDL TypeBSON $TypeGo Type (SDK)
StringDomainModels$StringAttributeType*StringAttributeType
String(n)DomainModels$StringAttributeType + Length*StringAttributeType{Length: n}
IntegerDomainModels$IntegerAttributeType*IntegerAttributeType
LongDomainModels$LongAttributeType*LongAttributeType
DecimalDomainModels$DecimalAttributeType*DecimalAttributeType
BooleanDomainModels$BooleanAttributeType*BooleanAttributeType
DateTimeDomainModels$DateTimeAttributeType*DateTimeAttributeType
DateDomainModels$DateTimeAttributeType*DateTimeAttributeType
AutoNumberDomainModels$AutoNumberAttributeType*AutoNumberAttributeType
BinaryDomainModels$BinaryAttributeType*BinaryAttributeType
EnumerationDomainModels$EnumerationAttributeType*EnumerationAttributeType
HashedStringDomainModels$HashedStringAttributeType*HashedStringAttributeType

Default Value Mapping

MDL DefaultBSON StructureGo Structure
DEFAULT 'text'Value: {$Type: "DomainModels$StoredValue", DefaultValue: "text"}Value: &AttributeValue{DefaultValue: "text"}
DEFAULT 123Value: {$Type: "DomainModels$StoredValue", DefaultValue: "123"}Value: &AttributeValue{DefaultValue: "123"}
DEFAULT TRUEValue: {$Type: "DomainModels$StoredValue", DefaultValue: "true"}Value: &AttributeValue{DefaultValue: "true"}
(calculated)Value: {$Type: "DomainModels$CalculatedValue", Microflow: <id>}Value: &AttributeValue{Type: "CalculatedValue", MicroflowID: id}

Default Value Syntax by Type

TypeDefault SyntaxExamples
StringDEFAULT 'value'DEFAULT '', DEFAULT 'Unknown'
IntegerDEFAULT nDEFAULT 0, DEFAULT -1
LongDEFAULT nDEFAULT 0
DecimalDEFAULT n.nDEFAULT 0, DEFAULT 0.00, DEFAULT 99.99
BooleanDEFAULT TRUE/FALSEDEFAULT TRUE, DEFAULT FALSE
AutoNumberDEFAULT nDEFAULT 1 (starting value)
EnumerationDEFAULT Module.Enum.ValueDEFAULT Shop.Status.Active, DEFAULT 'Pending'

Constraints

ConstraintSyntaxDescription
Not NullNOT NULLValue is required
Not Null with ErrorNOT NULL ERROR MESSAGE 'message'Required with custom error
UniqueUNIQUEValue must be unique
Unique with ErrorUNIQUE ERROR MESSAGE 'message'Unique with custom error

Constraints must appear in this order:

  1. NOT NULL [ERROR '...']
  2. UNIQUE [ERROR '...']
  3. DEFAULT <value>

Type Compatibility

MDL does not perform implicit type conversions. Types must match exactly.

Compatible type changes:

  • String(100) to String(200) – Increasing length
  • Integer to Long – Widening numeric type

Incompatible type changes:

  • String to Integer
  • Boolean to String
  • Any type to AutoNumber (if not empty)

Complete Example

/** Example entity demonstrating all data types */
@Position(100, 100)
CREATE PERSISTENT ENTITY Demo.AllTypes (
  /** Auto-generated ID */
  ItemNumber: AutoNumber NOT NULL UNIQUE DEFAULT 1,

  /** Short text field */
  Code: String(10) NOT NULL UNIQUE,

  /** Standard text field */
  Name: String(200) NOT NULL,

  /** Long text field */
  Description: String(unlimited),

  /** Integer counter */
  Counter: Integer DEFAULT 0,

  /** Large number */
  BigNumber: Long,

  /** Money amount */
  Amount: Decimal DEFAULT 0.00,

  /** Flag field */
  IsActive: Boolean DEFAULT TRUE,

  /** Timestamp */
  CreatedAt: DateTime,

  /** Date of birth (Mendix has no date-only type) */
  BirthDate: DateTime,

  /** File attachment */
  Attachment: Binary,

  /** Status from enumeration */
  Status: Enumeration(Demo.Status) DEFAULT 'Active'
)
INDEX (Code)
INDEX (Name, CreatedAt DESC);

Reserved Words

Complete reference for MDL reserved words and quoting rules.

Quoting Rules

Most MDL keywords now work unquoted as entity names, attribute names, parameter names, and module names. Common words like Caption, Check, Content, Format, Index, Label, Range, Select, Source, Status, Text, Title, Type, Value, Item, Version, Production, etc. are all valid without quoting.

Only structural MDL keywords require quoting: Create, Delete, Begin, End, Return, Entity, Module.

Quoted Identifiers

Quoted identifiers escape any reserved word using double-quotes (ANSI SQL style) or backticks (MySQL style):

mdl 1;
DESCRIBE ENTITY "ComboBox"."CategoryTreeVE";
LIST ENTITIES IN "ComboBox";
CREATE PERSISTENT ENTITY Module.VATRate ("Create": DateTime, Rate: Decimal);

Both double-quote and backtick styles are supported. You can mix quoted and unquoted parts: "ComboBox".CategoryTreeVE.

Words That Do NOT Require Quoting

The following common words can be used as identifiers without quoting:

CategoryWords
UI termsCaption, Content, Label, Text, Title, Format, Style
Data termsIndex, Range, Select, Source, Status, Type, Value, Item, Version
Security termsProduction
OtherCheck, and most domain-specific terms

Words That Require Quoting

The following structural MDL keywords must be quoted when used as identifiers:

KeywordExample with quoting
Create"Create": DateTime
Delete"Delete": Boolean
Begin"Begin": DateTime
End"End": DateTime
Return"Return": String(200)
Entity"Entity": String(200)
Module"Module": String(200)

Boolean Attribute Defaults

Boolean attributes auto-default to false when no DEFAULT is specified. Mendix Studio Pro requires Boolean attributes to have a default value.

CALCULATED Attributes

CALCULATED marks an attribute as calculated (not stored). Use CALCULATED BY Module.Microflow to specify the calculation microflow. Calculated attributes derive their value from a microflow at runtime.

ButtonStyle Values

ButtonStyle supports all values: Primary, Default, Success, Danger, Warning, Info.

Mendix Version Compatibility

Supported Mendix Studio Pro versions, feature availability matrix, and known limitations.

Supported Versions

mxcli supports Mendix Studio Pro versions 9.x through 11.x. Development and nightly testing targets five versions:

Studio Pro VersionMPR FormatNightly TestedStatus
9.xv1NoRead-only support
10.0 – 10.17v1NoSupported
10.18 – 10.23v2NoSupported
10.24 (LTS)v2YesSupported
11.0 – 11.5v2NoSupported
11.6v2YesPrimary development target
11.7 – 11.11v2NoSupported
11.12v2YesSupported
11.13v2YesSupported
11.14v2YesLatest tested

The exact patch versions are in the mendix-version matrix in .github/workflows/nightly.yml, which is authoritative – this table names the minors only.

MPR Format Versions

v1 (Mendix < 10.18)

  • Single .mpr SQLite database file
  • All documents stored as BSON blobs in the Contents column of the Unit table – there is no separate contents table
  • Self-contained – one file holds the entire project

v2 (Mendix >= 10.18)

  • .mpr SQLite file for metadata only – the Unit table has no Contents column
  • mprcontents/<XX>/<YY>/<UUID>.mxunit – one file per document
  • Better suited for Git version control (smaller, per-document diffs)

The library auto-detects the format. No configuration is needed.

Feature Availability Matrix

The tables below show exactly which features are available on each Mendix version. Data is sourced from sdk/versions/mendix-{9,10,11}.yaml.

Domain Model

FeatureMDL Syntax9.x10.0+10.18+11.0+
Persistent entitiesCREATE PERSISTENT ENTITYYesYesYesYes
Non-persistent entitiesCREATE NON-PERSISTENT ENTITYYesYesYesYes
Calculated attributesCALCULATED BY Module.MicroflowYesYesYesYes
Entity generalizationEXTENDS Module.ParentEntityYesYesYesYes
ALTER ENTITYALTER ENTITY ... ADD/DROP/RENAME–YesYesYes
View entitiesCREATE VIEW ENTITY ... AS SELECT––10.21+Yes

Microflows

FeatureMDL Syntax9.x10.0+10.6+11.0+
Basic microflowsCREATE MICROFLOW ... BEGIN ... ENDYesYesYesYes
Loop in branchesLOOP inside IF/ELSEYesYesYesYes
Loops over a list, while loopsLOOP $Item IN $List ..., WHILE ...9.0.4+YesYesYes
Show page with argumentsSHOW PAGE Module.Page (Param = $Value)9.7+YesYesYes
Calls in a task queueCALL MICROFLOW ... IN QUEUE ... [RETRY FIXED (...)], [RETRY EXPONENTIAL (...)], CALL JAVA ACTION ... IN QUEUE ... [RETRY ...]9.10+YesYesYes
Find and filter by expressionFIND $List WHERE ..., FILTER $List WHERE ...9.0.3+YesYesYes
Call external actionCALL EXTERNAL ACTION Module.Service.Action (...)9.19+ (with arguments: –)Yes (with arguments: 10.2+; INCLUDE ASSOCIATIONS: 10.11+)YesYes
Range of a listRANGE $List OFFSET ... LIMIT ...9.24+YesYesYes
SEND REST REQUESTSEND REST REQUEST Module.Service.Op–10.1+YesYes
SEND REST REQUEST arguments, result, body... WITH (...), $R = SEND REST REQUEST ..., ... BODY $Var–10.2+, 10.4+, 10.5+YesYes
Transform JSON with a data transformer$Out = TRANSFORM $Json WITH Module.Transformer–––11.4+
Execute database queryEXECUTE DATABASE QUERY ...9.24+YesYesYes
REST query parametersSEND REST REQUEST with QUERY params–––Yes
DB query runtime connectionEXECUTE DATABASE QUERY ... CONNECTION (...)–––11.1+

Below its version, each of these is refused by check -p and by exec before anything is written, with MDL-VERSION01, wherever it sits in the flow and in what an alter microflow or alter nanoflow inserts. The versions are the Mendix Model SDK’s own for the type each writes; before this, check -p passed them and the write refused them part-way through the script (ako/mxcli#1811). A FIND or FILTER BY Member = value writes the older find and filter every version has.

Pages

FeatureMDL Syntax9.x10.0+10.18+11.0+
Basic pagesCREATE PAGE ... { ... }YesYesYesYes
ALTER PAGEALTER PAGE ... SET/INSERT/DROP–YesYesYes
Pluggable widgetsDATAGRID, GALLERY, COMBOBOX, IMAGE–YesYesYes
Conditional visibilityVisible: <expression>–––Yes
Conditional editabilityEditable: <expression>–––Yes
Responsive column widthsTabletWidth: 6, PhoneWidth: 12–––Yes
Page parameters (entity)Params: ( $Item: Module.Entity )9.4+YesYesYes
Page parameters (primitive)Params: ( $Qty: Integer )–––11.6+
Page variablesVariables: ( ... )–10.20+10.20+Yes
Snippet parametersCREATE SNIPPET ... (Params: ( ... ))9.21+YesYesYes
Workflow actions on a buttonaction: open user task, open workflow, call workflow ...; complete task '...'9.0.2+; 9.19+YesYesYes
Design properties (Atlas v3)DesignProperties: (...)–––Yes
::: tip Widget Templates Pluggable widget templates are currently extracted from Mendix 11.6. When used on 10.x projects, the MPK augmentation system reconciles property differences. Some CE0463 (“widget definition changed”) errors may still occur.
::

Security

FeatureMDL Syntax9.x10.0+
Module rolesCREATE MODULE ROLEYesYes
User rolesCREATE USER ROLEYesYes
Entity access rulesGRANT READ/WRITE ON ...YesYes
Demo usersCREATE DEMO USERYesYes

Integration

FeatureMDL Syntax10.0+10.1+10.4+10.6+11.0+
OData clientCREATE CONSUMED ODATA SERVICEYesYesYesYesYes
Business events (9.24+)CREATE BUSINESS EVENT SERVICEYesYesYesYesYes
REST client with no operationCREATE CONSUMED REST SERVICE ... ( ... ) { }–YesYesYesYes
REST client basic authenticationauthentication: basic (...)–10.2+YesYesYes
REST client operations... { OPERATION ... }––YesYesYes
REST client from an OpenAPI documentCREATE CONSUMED REST SERVICE ... ( OpenAPI: ... )–––10.21+Yes
REST client headersHeaders: ('Name': 'Value')––YesYesYes
Database ConnectorCREATE DATABASE CONNECTIONYesYesYesYesYes
Database Connector queriesCREATE DATABASE CONNECTION ... { QUERY ... }–––10.12+Yes
Scheduled eventsCREATE SCHEDULED EVENT ... ( Repeat: ... )Yes (9.12+)YesYesYesYes
Module JAR dependencies, exclusionsALTER MODULE ... ADD JAR DEPENDENCY, ... ADD EXCLUSIONYes; 10.12+YesYesYesYes
REST client query paramsQUERY $param: Type––––Yes

A REST client operation’s body: json from $X is stored as Mendix 11’s JSON body (Rest$JsonBody) on 11.0+. Mendix 10 has no JSON body, so there it is written as the string body Mendix 10 has, holding the same text, and describe reads it back as body: template. Before this, mxcli wrote the JSON body on Mendix 10 too, and Studio Pro could not open the project.

Workflows

FeatureMDL Syntax9.x10.0+
Basic workflowsCREATE WORKFLOW, ALTER WORKFLOW9.0.2+Yes
User tasksUSER TASK ... OUTCOMES (...)9.19+Yes
A workflow parameterPARAMETER $WorkflowContext: Module.Entity9.18+Yes
A jumpJUMP TO ...9.0.5+Yes
Calling a workflow from a workflowCALL WORKFLOW ...; with (WorkflowContext = ...)9.6+; 9.18+Yes
An overview pageOVERVIEW PAGE ...9.11+Yes
AnnotationsANNOTATION '...'9.15+Yes
Multi-user tasksMULTI USER TASK ...9.22+Yes
Multi-user task rules majority and thresholdDECIDE BY MAJORITY ..., DECIDE BY THRESHOLD ...NoYes
Waiting for all usersAWAIT ALL USERSNo10.2+
Deciding by a microflowDECIDE BY MICROFLOW ...No10.3+
Group targetingTARGETING GROUPS XPATH ..., TARGETING GROUPS MICROFLOW ...No11.2+
Parallel splitsPARALLEL SPLITYesYes
Waiting for a notificationWAIT FOR NOTIFICATIONNoYes
Waiting for a timerWAIT FOR TIMERNo10.6+
A timer boundary event of no kindBOUNDARY EVENT TIMER ...No10.14+ (MDL-WF07 refuses it from 11.0)
Interrupting and non-interrupting timer boundary eventsBOUNDARY EVENT [NON] INTERRUPTING TIMER ...No10.20+
Locking and unlocking a workflowLOCK WORKFLOW ..., UNLOCK WORKFLOW ...NoYes
Starting a workflow, setting a task outcome, opening a user task or a workflow (microflow)CALL WORKFLOW, SET TASK OUTCOME, OPEN USER TASK, OPEN WORKFLOW9.0.2+Yes
Aborting and restarting a workflow (microflow)WORKFLOW OPERATION ABORT ..., WORKFLOW OPERATION RESTART ...9.2+Yes
Pausing and continuing a workflow (microflow)WORKFLOW OPERATION PAUSE ..., WORKFLOW OPERATION CONTINUE ...9.3+Yes
Getting workflow data (microflow)GET WORKFLOW DATA ... AS ...9.10+Yes
Retrying and unpausing a workflow (microflow)WORKFLOW OPERATION RETRY ..., WORKFLOW OPERATION UNPAUSE ...9.12+Yes
Notifying a workflow (microflow)NOTIFY WORKFLOW ... TARGET ...NoYes
Getting an object’s workflows, or a workflow’s activity records (microflow)GET WORKFLOWS FOR ..., GET WORKFLOW ACTIVITY RECORDS ...No10.4+

Every write refuses an element whose type the project’s Mendix version does not have yet, naming the type, the version that introduced it and the project’s version (“this Mendix 9.24.30 project cannot store Workflows$WaitForNotificationActivity (Mendix 10.0.0+)”). The versions are each type’s own, from the Mendix Model SDK. Below their versions, the wait activities, the timer boundary events, a lock or unlock naming a workflow, and the workflow actions of a microflow (in create microflow and in what alter microflow inserts) are also refused up front, by check -p and by exec before anything is written, with MDL-VERSION01 (ako/mxcli#1787). So is a workflow itself on 9.0.0 and 9.0.1, which predate Workflows$Workflow (9.0.2): create workflow, create or modify workflow and alter workflow (ako/mxcli#1809), and what a workflow states whose version is later still, listed above: on a stamped 9.x project the write refuses every workflow with a parameter below 9.18 and every user task below 9.19 (ako/mxcli#1811). Before this, such a type was written, and the project did not load (TypeCacheUnknownTypeException, ako/mxcli#1774). Where an older version stores the same thing in another shape (the parallel split’s end marker, user tasks), that shape is written instead, as listed under “Properties Stored in an Older Shape”.

Below Mendix 11.3 a workflow’s main flow has no stored start or end activity: 11.1 and 11.2 keep them implicit, and 10.x and 11.0 do not have the start activity type at all. mxcli writes the shape the project’s version stores, and the header’s annotation '…' is then the workflow’s own note rather than the start activity’s; describe prints it the same way on every version. Before this, mxcli wrote the 11.3 shape on every version: a 10.24 or 11.0 project no longer loaded, and 11.1 and 11.2 failed mx check with CE6671 and CE6689 (ako/mxcli#1754).

FeatureMDL Syntax9.x10.0+
Navigation profilesALTER NAVIGATION ...YesYes
Menu itemsMENU ITEM ...YesYes
Home pagesHOME PAGE Module.PageYesYes
A progressive web app’s settingsPROGRESSIVE WEB APP ( ... )9.0.3+Yes
A native profile’s home pageCREATE OR MODIFY NAVIGATION NativePhone HOME PAGE ...9.4+Yes
Menu item iconsMENU ITEM '...' ( ..., Icon: Module.Collection.icon )9.20+Yes
A not-found pageNOT FOUND PAGE Module.PageNo10.13+

Each “+” version above that is not a whole major is the Model SDK’s version of a type or property mxcli writes for the feature, with no older shape; below it, check -p and exec refuse the statement with MDL-VERSION01 before anything is written (ako/mxcli#1811).

OQL (View Entity queries)

FeatureMDL Syntax10.18+11.0+
Basic SELECTSELECT, FROM, WHEREYesYes
Aggregate functionsCOUNT, SUM, AVG, MIN, MAX, GROUP BYYesYes
String aggregateSTRING_AGG(expr, 'separator')10.24.22+ only11.2+
SubqueriesInline subqueries in SELECT/WHEREYesYes
JOIN typesINNER/LEFT/RIGHT/FULL JOINYesYes

MPR Format & Infrastructure

FeatureMinimum VersionNotes
MPR v2 (mprcontents/)10.18Per-document files for Git compatibility
Association storage format11.0New BSON encoding for associations
Portable app format11.6New deployment format

BSON Differences Across Versions

The Mendix metamodel evolves across versions. The reflection data shows ~42% type growth from Mendix 9.0 to 11.6. Key structural differences:

View Entities (10.x vs 11.x)

View entities arrived in Mendix 10.18 with the query stored inline. mxcli stores it in a separate ViewEntitySourceDocument, which the entity’s source names; both are 10.21 types, so create view entity needs 10.21 and is refused by check -p below it (ako/mxcli#1811); before, check -p passed it on 10.18 to 10.20 and the write refused it. The BSON structure also differs between 10.x and 11.x. In Mendix 10.x, the OqlViewEntitySource object has an Oql field that stores the OQL query inline (in addition to the separate ViewEntitySourceDocument). Mendix 11.0 removed the inline Oql field. The writer detects the project version and includes the inline field for 10.x projects.

Page Parameters (9.4+, with an 11.5 tail)

Page parameters are not an 11.x feature. Pages$PageParameter and Page.parameters were introduced in 9.4.0, and Pages$PageSettings.parameterMappings — the arguments a SHOW PAGE passes — in 9.7.0 (measured from the Mendix Model SDK’s StructureVersionInfo records). mxcli refused them on 10.x until mendixlabs/mxcli#1121; the 11.0 floor in the version registry came from an illustrative sample table in a proposal, not from a measurement.

What is version-specific is the optional/default-value half: PageParameter.IsRequired and PageParameter.DefaultValue arrived in 11.5.0. MDL cannot express either — every parameter it writes is required with no default — so the writer omits both below 11.5. Emitting them anyway is the “never invent a key” failure: mxbuild accepts unknown properties, and Studio Pro throws InvalidOperationException at MprProperty.cs.

Design Properties (Atlas v2 vs v3)

Atlas UI v3 (Mendix 11.0+) introduced new design properties like “Card style” and “Disable row wrap” for containers. These don’t exist in the Atlas v2 theme bundled with 10.x, causing CE6083.

Properties Stored in an Older Shape

Some properties kept their meaning and changed how they are stored. mxcli builds the current shape and, on every write, rewrites it into the shape the project’s version stores. describe reads both shapes (ako/mxcli#1209, ako/mxcli#1163). The older shapes were measured on Studio Pro-authored 9.24 documents and with mx convert 9.24.30:

PropertyBelowStored as
A widget’s design property10.2Type (Toggle / DropDown), StringValue, BooleanValue on the entry, instead of a typed Value
A data view’s page or snippet parameter10.0PageParameter / SnippetParameter names on the data source, instead of a SourceVariable
A data view’s editability10.7Editable: true|false, instead of Editability
A text box’s, text area’s or date picker’s placeholder10.11Placeholder text, instead of a PlaceholderTemplate
Any data source’s ForceFullObjects10.8Not stored
The PersistentId of a workflow, activity, outcome or boundary event10.21Not stored
The end of a parallel split path (Workflows$EndOfParallelSplitPathActivity, a type introduced in 10.21)10.21Not stored: a path holds its activities and nothing more (Studio Pro 9.4)
A user task (Workflows$SingleUserTaskActivity / MultiUserTaskActivity, types introduced in 10.12)10.12A Workflows$UserTask whose UserTaskCompletion is a SingleInputCompletion, or a MultiInputCompletion holding the rule, the participants and, from 10.2, AwaitAllUsers (Studio Pro 9.24, ako/mxcli#1771)
A user task’s targeting (targeting users xpath […], targeting users microflow …, none)11.2A UserSource (XPathBasedUserSource, MicroflowBasedUserSource, EmptyUserSource) holding the same value, instead of a UserTargeting (ako/mxcli#1771)
A constant’s value in a configuration (alter settings constant … in configuration)10.9A flat Value string on the override, instead of a Settings$SharedValue under SharedOrPrivateValue
A page’s flow call argument that is the enclosing object (call microflow M.F(P = $currentObject), call nanoflow …)10.10Not stored: Studio Pro infers it. A page parameter, snippet parameter or widget argument is stored as before, as a Variable

What an older shape cannot hold is refused, naming the element, rather than dropped:

  • a Spacing, colour-picker or toggle-button-group design property below 10.2;
  • conditional data view editability below 10.7;
  • a placeholder with parameters below 10.11;
  • a data view fed by another widget below 10.0;
  • a private constant value in a configuration below 10.9;
  • a page’s flow call argument that is any other expression (a literal, an attribute path) below 10.10;
  • a user task targeting groups (targeting groups xpath …, targeting groups microflow …) below 11.2, where a task targets users only;
  • a multi-user task below 9.22, decide by majority or decide by threshold below 10.0, decide by microflow below 10.3, await all users below 10.2, and a boundary event on a user task below 10.14. The user-task features are also refused up front, by check -p and by exec before anything is written, with MDL-VERSION01 (ako/mxcli#1771);
  • an aggregate over an expression (sum $L of …), reduce, all or any below 10.0. The plain count, sum, average, minimum and maximum are written on every version (ako/mxcli#1163).

Before this, describe on 9.24 printed none of these properties, so a describe → exec round trip removed them. Writing a data view on 9.24 was refused outright.

A published OData service is not an older shape of the same document. Before 10.4 it is a different document (Rest$PublishedODataService, not ODataPublish$PublishedODataService2), which mxcli does not write. create published odata service is refused below 10.4 with MDL-VERSION01, by check -p and by exec before anything is written. Mendix 9.24 cannot load a project that holds the 10.4 document.

Widget Type Definitions

Pluggable widget PropertyTypes change between Mendix versions. A widget template extracted from 11.6 may have more or fewer properties than the widget installed in a 10.24 project. The widget augmentation system handles this by reconciling templates against the project’s .mpk files.

The schema itself also gained keys over time. A key the project’s version does not declare is left out of the schema mxcli writes, by the same version data the property floor uses. For example, a return type’s AssignableTo arrived in 9.20, so a 9.0–9.19 schema stores EntityProperty, IsList and Type, as Studio Pro 9.1 and 9.4 do (ako/mxcli#1163).

Widget Template Versions

Embedded widget templates are extracted from Mendix 11.6 and cover:

WidgetTemplateWidget ID
Combo boxcombobox.jsoncom.mendix.widget.web.combobox.Combobox
Data grid 2datagrid.jsoncom.mendix.widget.web.datagrid.Datagrid
Gallerygallery.jsoncom.mendix.widget.web.gallery.Gallery
Imageimage.jsoncom.mendix.widget.web.image.Image
Text filterdatagrid-text-filter.jsoncom.mendix.widget.web.datagridtextfilter.DatagridTextFilter
Number filterdatagrid-number-filter.jsoncom.mendix.widget.web.datagridnumberfilter.DatagridNumberFilter
Date filterdatagrid-date-filter.jsoncom.mendix.widget.web.datagriddatefilter.DatagridDateFilter
Dropdown filterdatagrid-dropdown-filter.jsoncom.mendix.widget.web.datagriddropdownfilter.DatagridDropdownFilter

MPK Augmentation

When opening a project, mxcli checks the project’s widgets/ folder for .mpk packages. If a widget’s installed version differs from the embedded template, the augmentation system:

  1. Extracts the XML property definition from the .mpk
  2. Adds properties found in .mpk but missing from the template
  3. Removes properties in the template but not in the .mpk
  4. Preserves the BSON structure (IDs, nesting, cross-references)

This reduces CE0463 (“widget definition changed”) errors from widget version drift. See Widget Templates for details.

Version Gates in MDL Scripts

MDL scripts can use -- @version: directives to conditionally execute features based on the target Mendix version. This is used in the doctype integration tests to skip features incompatible with older versions:

-- Runs on all versions
CREATE ENTITY MyModule.Customer (Name: String(200));

-- @version: 11.0+
-- Only runs on Mendix 11.0 and later
CREATE VIEW ENTITY MyModule.ActiveCustomers (...) AS
  SELECT c.Name FROM MyModule.Customer AS c WHERE c.IsActive;

-- @version: 10.6..10.24
-- Only runs on Mendix 10.6 through 10.24
CREATE ENTITY MyModule.LegacyConfig (...);

-- @version: any
-- Resets to unconditional (runs on all versions)
CREATE ENTITY MyModule.Universal (...);

Directive formats:

  • -- @version: 11.0+ – minimum version (run on 11.0 and later)
  • -- @version: 10.6..10.24 – version range (inclusive)
  • -- @version: ..10.24 – maximum version only
  • -- @version: any – reset to unconditional

The directive applies to all lines until the next -- @version: directive.

MxBuild Compatibility

The mx validation tool must match the project’s Mendix version:

# Auto-download the correct MxBuild version
mxcli setup mxbuild -p app.mpr

# Check the project
mxcli docker check -p app.mpr

MxBuild is downloaded on demand and cached in ~/.mxcli/mxbuild/{version}/.

Platform Support

mxcli runs on:

PlatformArchitecture
Linuxamd64, arm64
macOSamd64, arm64 (Apple Silicon)
Windowsamd64, arm64

No CGO or C compiler is required – the binary is fully statically linked using pure Go dependencies.

Common Mistakes and Anti-Patterns

Frequent errors encountered when writing MDL scripts or using the SDK, with explanations of why they occur and how to avoid them.

EXTENDS Must Appear Before (

CRITICAL: The EXTENDS clause goes BEFORE the opening parenthesis, not after.

-- CORRECT: EXTENDS before (
CREATE PERSISTENT ENTITY Module.ProductPhoto EXTENDS System.Image (
  PhotoCaption: String(200)
);

-- WRONG: EXTENDS after ) = parse error!
CREATE PERSISTENT ENTITY Module.Photo (
  PhotoCaption: String(200)
) EXTENDS System.Image;

String Attributes Need Explicit Length

Always specify an explicit length for String attributes. While String defaults to 200, it is clearer and safer to use String(200) explicitly. Use String(unlimited) for long text.

-- GOOD: Explicit length
Name: String(200)
Description: String(unlimited)
Code: String(10)

-- AVOID: Implicit default length
Name: String

Never Create Empty List Variables as Loop Sources

If processing imported data, accept the list as a microflow parameter. DECLARE $Items List of ... = empty followed by LOOP $Item IN $Items is always wrong – the loop body will never execute.

-- WRONG: Loop over empty list does nothing
DECLARE $Items List of Module.Item = empty;
LOOP $Item IN $Items BEGIN
  -- This never executes!
END LOOP;

-- CORRECT: Accept the list as a parameter
CREATE MICROFLOW Module.ProcessItems
  PARAMETER $Items: List of Module.Item
BEGIN
  LOOP $Item IN $Items BEGIN
    -- Process each item
  END LOOP;
  RETURN true;
END;

Never Use Nested LOOPs for List Matching

Loop over the primary list and use RETRIEVE with a filter for O(N) lookup. Nested loops are O(N^2).

-- WRONG: O(N^2) nested loops
LOOP $Item IN $Items BEGIN
  LOOP $Match IN $Targets BEGIN
    IF $Item/Key = $Match/Key THEN
      -- ...
    END IF;
  END LOOP;
END LOOP;

-- CORRECT: O(N) with RETRIEVE
LOOP $Item IN $Items BEGIN
  RETRIEVE $Match FROM $Targets WHERE Key = $Item/Key LIMIT 1;
  IF $Match != empty THEN
    -- ...
  END IF;
END LOOP;

Association ParentPointer/ChildPointer Semantics Are Inverted

CRITICAL: Mendix BSON uses inverted naming for association pointers. This is counter-intuitive and a common source of bugs.

BSON FieldPoints ToMDL Keyword
ParentPointerFROM entity (FK owner)FROM Module.Child
ChildPointerTO entity (referenced)TO Module.Parent

For example, CREATE ASSOCIATION Mod.Child_Parent FROM Mod.Child TO Mod.Parent stores:

  • ParentPointer = Child.$ID (the FROM entity owns the foreign key)
  • ChildPointer = Parent.$ID (the TO entity is being referenced)

This affects entity access rules: MemberAccess entries for associations must only be added to the FROM entity (the one stored in ParentPointer). Adding them to the TO entity triggers CE0066 “Entity access is out of date”.

The same convention applies in domainmodel.Association: ParentID = FROM entity, ChildID = TO entity.

BSON Storage Names vs Qualified Names

Mendix uses different “storage names” in BSON $Type fields than the “qualified names” shown in the TypeScript SDK documentation. Using the wrong name causes TypeCacheUnknownTypeException when opening in Studio Pro.

Qualified Name (SDK/docs)Storage Name (BSON $Type)Note
CreateObjectActionCreateChangeAction
ChangeObjectActionChangeAction
DeleteObjectActionDeleteAction
CommitObjectsActionCommitAction
RollbackObjectActionRollbackAction
AggregateListActionAggregateAction
ListOperationActionListOperationsAction
ShowPageActionShowFormAction“Form” was original term for “Page”
ClosePageActionCloseFormAction“Form” was original term for “Page”

When adding new types, always verify the storage name by:

  1. Examining existing MPR files with the mx tool or SQLite browser
  2. Checking the vendored types in modelsdk/gen/<domain>/types.go
  3. Looking at the read cases in mdl/backend/modelsdk/microflow_read_actions.go

Mendix Expression String Escaping

When generating Mendix expression strings (e.g., in expressionToString()), single quotes within string literals must be escaped by doubling them: 'it''s here'. Do NOT use backslash escaping (\'). This matches Mendix Studio Pro’s expression syntax.

Microflows: Unsupported Constructs

These constructs will cause parse errors:

UnsupportedUse Instead
CASE ... WHEN 'String' ... ELSE ...Bare enum values, one branch per value including (empty) — CASE itself IS supported for enum splits
TRY ... CATCH ... END TRYON ERROR BEGIN ... END ERROR blocks on specific activities

Boolean Attributes Must Have Defaults

Mendix Studio Pro requires Boolean attributes to have a DEFAULT value. If no DEFAULT is specified, MDL auto-defaults to false.

-- GOOD: Explicit default
IsActive: Boolean DEFAULT true

-- RISKY: Implicit default to false
Deleted: Boolean

Always Validate Before Presenting to Users

Always run syntax and reference checks before delivering MDL scripts:

# Syntax + anti-pattern check
mxcli check script.mdl

# With reference validation against a project
mxcli check script.mdl -p app.mpr --references

Error Messages Reference

Common error messages from Studio Pro, mxcli, and the MDL parser, with explanations and solutions.

Studio Pro Errors

These errors appear when opening a project in Studio Pro after modification by mxcli.

TypeCacheUnknownTypeException

TypeCacheUnknownTypeException: The type cache does not contain a type
with qualified name DomainModels$Index

Cause: The BSON $Type field uses the qualifiedName instead of the storageName. These are often identical, but not always.

Solution: Use the correct storage name:

qualifiedName (wrong)storageName (correct)
DomainModels$EntityDomainModels$EntityImpl
DomainModels$IndexDomainModels$EntityIndex

Look up the type’s SetTypeName(...) call in modelsdk/gen/<domain>/types.go, or dump a Studio Pro-authored document.

CE0463: Widget definition changed

CE0463: The widget definition of 'DataGrid2' has changed.

Cause: The widget’s WidgetObject properties do not match its PropertyTypes schema. This happens when:

  • A widget template is missing properties
  • Property values have incorrect types
  • The template was extracted from a different Mendix version

Solution:

  1. Create the same widget manually in Studio Pro
  2. Extract its BSON from the saved project
  3. Compare the template’s object section against the Studio Pro version
  4. Update sdk/widgets/templates/<version>/<Widget>.json to match

See the debug workflow in .claude/skills/debug-bson.md for step-by-step instructions.

CE0066: Entity access is out of date

CE0066: Entity access for 'MyModule.Customer' is out of date.

Cause (1): the rules no longer cover the entity’s members. An access rule names the members it governs, so an entity that has gained an attribute or an association — or lost one — leaves every rule on it out of date. This is the usual cause for a model that arrived from somewhere else: a module imported or updated outside Studio Pro whose author never pressed Update security, a hand-edited .mpr, a merge.

Solution: run the headless equivalent of that button:

mxcli -p app.mpr -c "update security MyModule"

It reports what it changed (Reconciled 3 access rule(s) in module MyModule) and writes nothing when the rules already match. See UPDATE SECURITY. mxcli marketplace install and mxcli marketplace update run it for the module they copy in.

Cause (2): association MemberAccess entries on the wrong entity. In Mendix, association access rules must only be on the FROM entity (the one stored in ParentPointer), not the TO entity.

Solution: Ensure MemberAccess entries for associations are added only to the entity that owns the foreign key (the FROM side of the association). Remove any association MemberAccess entries from the TO entity.

System.ArgumentNullException (ValidationRule)

System.ArgumentNullException: Value cannot be null.

Cause: A validation rule’s Attribute field uses a binary UUID instead of a qualified name string. The metamodel specifies BY_NAME_REFERENCE for this field.

Solution: Use a qualified name string (e.g., "Module.Entity.Attribute") for the Attribute field in ValidationRule BSON, not a binary UUID.

mxcli Check Errors

These rule IDs appear in mxcli check output and in LSP diagnostics in VS Code. They fire before MxBuild runs, so most pluggable-widget mistakes are caught at authoring time.

MDL-WIDGET01: Unknown pluggable widget property

page MyModule.OrderList: widget `cb1` (combobox) has no property
`optionsSourcType` — did you mean `optionsSourceType`? [MDL-WIDGET01]

Cause: The property key written on a pluggable widget is not declared in the widget’s .def.json (the extracted schema from its .mpk). Usually a typo; sometimes a property that exists in a different widget but not this one.

Solution:

  1. Compare the key against the widget’s known properties — mxcli widget describe <name> lists them (or describe widget type <name>; in MDL).
  2. Use the suggested replacement if one is offered (Levenshtein-nearest match).
  3. If the property genuinely doesn’t exist on this widget version, check that .mxcli/widgets/ has the latest schema: mxcli refresh catalog -p app.mpr re-extracts any .mpk whose mtime changed.
  4. If the property was just added by a .mpk upgrade, make sure mxcli init or widget init was run after the upgrade.

This rule also fires in the LSP — the property key shows as a red squiggle while you type.

MDL-WIDGET02: Legacy native widget on upgraded project

page MyModule.OrderList: widget `OrdersGrid` uses deprecated native
`Forms$DataGrid` (deprecated from Mendix 11.0.0) — migrate to pluggable
Datagrid 2.x (`DATAGRID` keyword resolves to this on 11.0+) (project is
on 11.9.0) [MDL-WIDGET02]

Cause: Studio Pro does not auto-migrate native-stack widgets (e.g. Forms$DataGrid) to their pluggable replacements during a Mendix major-version upgrade. After upgrading from Mendix 10.x to 11.x, your pages can still contain the native widget unless you opened them and migrated by hand.

Solution:

  1. Run mxcli check --post-migration -p app.mpr to get the full list of pages and widgets to migrate.
  2. Open each reported page in Studio Pro and replace the native widget with its pluggable equivalent. For DataGrid, this means using the DATAGRID MDL keyword (which resolves to pluggable Datagrid 2.x on Mendix 11.0+) or pluggablewidget 'com.mendix.widget.web.datagrid.Datagrid'.
  3. Re-run the scan to confirm the page is clean.

The deprecated-widget catalog is hand-maintained in mdl/executor/keyword_dispatch.go (LegacyWidgets). Open an issue if you spot a native widget that should be on the list.

MDL-LISTOP01: Undefined iterator in a FILTER/FIND predicate

filter($L, …): '$item' is not defined in this microflow. A filter predicate is
evaluated once per item and Mendix binds the item to '$currentObject' — no other
iterator name exists, so mxbuild rejects this with CE0109 "Undefined variable
'item'". [MDL-LISTOP01]

Cause: A FILTER/FIND predicate navigates from a variable the microflow never binds. Mendix evaluates the predicate once per item with the item bound to $currentObject, and defines no other iterator name — so filter($L, $item/Amount > 0) compiles to a reference to nothing.

Solution:

  1. Use $currentObject/ for the item under test: filter($L, $currentObject/Amount > 0).
  2. Or write the attribute bare — filter($L, Amount > 0) — and mxcli resolves it against the list’s entity and stores $currentObject/Amount.

The rule keys on scope, not on the name. $item is perfectly valid in a predicate when it is the enclosing loop’s iterator, which is how the O(N) lookup idiom is written — inside loop $item in $L, find($Others, Key = $item/Key) navigates the loop’s variable and is not flagged.

MDL-STUB01: One flow declared twice in a script set

MyFirstModule.Export is declared by 2 `create or modify` statements in this script
set: 13-actions.mdl:40 (mdl 0), 30-export.mdl:12 (mdl 1). Run in order, the first
replaces what the last one stored, on every run; ... A self-recursive flow no
longer needs a placeholder (#843): drop the stub and keep the real statement [MDL-STUB01]

Cause: A script set — mxcli check or mxcli fmt --upgrade given several files, read as one run in the order given — declares one microflow or nanoflow with two create or modify statements: a placeholder (“stub”) that earlier scripts can reference, followed by the real flow. Run in order, the stub replaces the stored real flow and the real statement restores it, on every run. Under different language headers it is worse: the mdl 0 stub rebuilds the real flow, the mdl 1 real statement can be refused (its change cannot be spliced into the stub), and the project keeps running the placeholder with mx check reporting nothing (ako/mxcli#905).

Solution: Drop the stub. Since #843 a self-recursive flow is created in one statement, and a script that only references the flow does not need it to exist until it runs. If the stub has to stay, give both files the same header: mxcli fmt --upgrade -w -p app.mpr over all the files decides the header for the pair together — added to both or to neither — where upgrading the files one at a time could split it.

It is a warning. One file checked alone is not read as a set.

MDL-SEC21: A used flow left without access (CE0106)

✗ microflow Shop.MF_Button has no allowed role but is used from page Shop.P_Main —
MxBuild reports CE0106 "At least one allowed role must be selected if the microflow
is used from navigation, a page, a nanoflow or a published service" at security
level Production. This script creates it as a NEW microflow, ... [MDL-SEC21]
  → add grant execute on microflow Shop.MF_Button to Shop.Admin; — or, to rebuild a
    flow that already exists without losing its access, use `create or modify
    microflow` instead of drop + create

Cause: With a project (check -p), the script leaves a microflow or nanoflow with no allowed module role while a page, snippet, layout, nanoflow, menu document or navigation profile names it. Measured on Mendix 11.14, MxBuild reports CE0106 for each of those uses (even from an unused snippet, menu document or nanoflow), but not for a published REST operation, a microflow called only from another microflow, or an excluded page. The usual way to get there is drop microflow in one run and create microflow in a later one: the later create is a new flow, and a new document gets no access. A new flow in a script that creates it and a page using it, with no grant, is the other. Dropping and recreating in the same run, or using create or modify, keeps the stored roles.

Solution: Add the grant execute on microflow … (or … on nanoflow …) the suggestion names, or rebuild the flow with create or modify instead of dropping it.

It is an error at security level Prototype or Production (the stored level, or the one the script sets) and a warning at Off, where MxBuild does not check it. Only what the script changes is reported: a flow that already had the problem before the script, and that the script does not touch, is left to mxcli docker check.

MDL-SEC22: A call across entity access (CE0114)

✗ microflow MyModule.DS_Tile_Count does not apply entity access (@applyentityaccess(false)
in this script) but calls microflow MyModule.SUB_Scoped_List, which does
(@applyentityaccess(true) as stored) — MxBuild reports CE0114 "A microflow that does not
apply entity access can only call microflows that also do not apply entity access." [MDL-SEC22]

Cause: With a project (check -p), a microflow without “Apply entity access” calls a microflow — or decides on a rule (if Mod.Rule(…)) — that has it. Measured on Mendix 11.13 and 11.14, MxBuild refuses only that direction, at every security level; on -> off, on -> on, off -> off, a nanoflow caller and an excluded caller all build. Both settings are read from the project and simulated through the script (@applyentityaccess, @applyentityaccess(false), and an absent annotation on create or modify keeping what is stored).

Solution: Put @applyentityaccess on the caller, or call a flow that does not apply entity access. Turning it off on the callee also builds, but widens what that flow may read and write for every caller.

Only what the script changes is reported, and a callee neither the project nor the script holds is not judged.

MDL-I18N01: A tab page caption without the default language (CE4899)

✗ Administration.Account_Overview: tab page caption tabPage2 ("Local Users") has no
de_DE text once this script makes de_DE the default language — mxbuild reports
CE4899 "Empty caption" [MDL-I18N01]
  → change the default language before creating the document, or give it a de_DE
    text after the change: `alter page Administration.Account_Overview { set (Caption: '…') on tabPage2; };`

Cause: With a project (check -p), the script changes DefaultLanguageCode while a tab page caption has no text in the new default: one stored in the project, or one a create page / create snippet earlier in the script wrote in the old default. Mendix has no language-neutral text — a caption is stored per language, and a new one under the default at the time. Measured on Mendix 11.14, a tab page caption is the one caption kind MxBuild refuses when the default has no text (CE4899 “Empty caption. [German, Germany]”); a page title, button, label or menu item caption builds and shows the placeholder in Studio Pro instead. A stock app’s Administration.Account_Overview has an en_US-only tabPage2, so switching such an app to another default fails the build.

Solution: Change the default language before the script creates its pages, or give each caption a text in the new default after the change — alter page … { set (Caption: '…') on <tab>; }; (or alter snippet) writes the default language. alter settings language (DefaultLanguageCode: …) prints the list when it runs, and mxcli lint reports the project’s existing ones as QUAL006. A page the script creates or rewrites after the change is written in the new default and is not reported.

MDL-DUPDEF: Element defined twice in one script

microflow already defined in this script: Sales.ACT_Submit (first defined at
statement 4) [MDL-DUPDEF]

Cause: Two plain create statements in one script make the same element — same kind, same qualified name — and nothing between them drops or renames the first. exec would create the first and refuse the second as “already exists”, after everything before it had been written.

Solution: Keep one of the two. To change an element the script already made, use create or modify (it is never reported), or drop it before re-creating it. A rename frees the old name and takes the new one, so create after rename … to New is reported for New and not for the old name.

check --references (-p app.mpr) adds the project side: a plain create of an element the project already has is reported as “already exists in project”, for every create exec refuses that way — documents, module and user roles, demo users, configurations.

MDL-DUPNAME: Name already taken in the module

cannot create nanoflow Sales.ACT_Submit: microflow Sales.ACT_Submit already has
that name — microflows, nanoflows and rules share one name space per module,
whatever their folder, and names compare case-insensitively (Mendix CE0122)
(statement 3) [MDL-DUPNAME]

Cause: The statement gives an element a name Mendix will not let it share. Measured with mx check, three groups of kinds each share one name space per module:

KindsMendix error
microflows, nanoflows, rulesCE0122 “Duplicate document name”
pages, snippets, layoutsCE0122 “Duplicate document name”
entities (incl. view and external), associations, enumerationsCE0065 “Entities, associations and enumerations cannot share names”

Every other kind is a name space of its own. A microflow may share its name with a page, constant, enumeration, java action or workflow. Folders do not separate names, and names compare case-insensitively: Sales.act_submit next to microflow Sales.ACT_Submit is a duplicate of the same kind (ako/mxcli#806).

check reports the clash between two statements of one script. check --references and exec’s pre-flight also report a statement that clashes with what the project already holds — a create, a rename … to or a cross-module move … to Module. exec refuses it before writing anything. The model it would write does not build, so this holds under every language version, create or modify included. create or modify of the element that already has the name, spelled as it is stored, still modifies it.

Solution: Pick another name, or rename the other element first. To change an existing element, spell its name as it is stored. A different folder does not help.

MDL-DEPRnnn: Deprecated spelling

line 1: `create or replace …` (enumeration) is deprecated; write `create or modify …`
— same meaning. Refused from `mdl 2`. (mxcli help MDL-DEPR001) [MDL-DEPR001]

Cause: The script uses an alias that MDL is consolidating away (ADR-0010). The alias means exactly what the canonical form means, so the statement runs unchanged. The warning names the canonical form, and the language version whose header (mdl <n>;) will refuse the alias (ADR-0011).

Solution: Write the canonical form the warning names. The rewrite is a mechanical keyword swap, and the suggestion line says which one.

check and exec report these as warnings. To fail the run on one, for example in CI over documentation and examples, pass --deprecations=error.

--deprecations=error does not gate the MDL-V1-* warnings — what a headerless (mdl 0) script still has to change before it can carry mdl 1; — so a script can pass it and still not parse under mdl 1. mxcli check --v1=error fails the run on those; --deprecations=error --v1=error together is the full mdl 1 readiness gate, and mxcli fmt --upgrade --header is the fix.

mxcli help <code> prints the entry for any MDL-DEPRnnn or MDL-V1-* code, mxcli syntax <topic> --deprecated lists a topic’s old spellings, and Language Versions and Migration tabulates them all.

The registry of deprecated spellings is mdl/deprecation/deprecation.go. It holds the code, old form, canonical form, rewrite and removal version of each entry. A spelling is only registered where it means exactly the same as its canonical form. create or replace view entity, for example, drops and recreates the view entity, so it is not reported. Likewise show is reported as MDL-DEPR002 only where its canonical form is list (plurals, relationship queries and the summary tables), and as MDL-DEPR090 where it is describe with the same output. show entity X and show version are neither: the first has no mdl 1 statement (MDL-V1-SHOWSUMMARY), the second is a session command (MDL-V1-SESSION).

mxcli Parser Errors

MDL-MCPSERVER01: A published MCP service the runtime will not start

published mcp service Shop.OrdersMCP: EndpointPath "mcp" is reserved: it cannot be
'mcp' or end in '/mcp' [MDL-MCPSERVER01]

Cause: A published MCP service document is not validated by mxbuild; AgentEditorCommons reads it at startup and calls MCPServer.CreateMCPServer, which throws for a reserved or sub-path EndpointPath (mcp, …/mcp, any /), a missing Version, EndpointPath or ProtocolVersion, Scopes without an AuthorizationServerUrl, an AuthorizationServerUrl without an AuthenticationMicroflow or not an absolute http(s) URL. The app still starts, without that server, and logs the error. A tool without a Microflow, or two tools with one name, is refused too. As a warning: AuthenticationMicroflow, AuthorizationServerUrl and Scopes are ignored while AuthenticationEnabled is false, and AuthenticationEnabled: true with no authentication microflow accepts every request.

Solution: Fix the property the message names; create published mcp service documents each.

MDL-AGENT02: An agent created without a selected model

agent Shop.SupportAgent has no SelectedModel: Agent Editor 2.2+ refuses to import it at
startup ("Model with name  is referenced but could not be found"), and that stops the
import of every agent [MDL-AGENT02]

Cause: Agent Editor 2.2+ (AgentEditorCommons AgentFromModel_Validate) requires the agent’s selectedModel id and retrieves the deployed model by it. mxbuild does not read agent documents, so the build is green; ASU_AgentEditor refuses the agent at startup and stops importing the rest.

Solution: Add SelectedModel: '<model id>' (e.g. 'eu.anthropic.claude-sonnet-5'), the id of a model the Model document’s resource offers. create or modify agent keeps a stored value when the key is left out, so check --references and exec warn on it only when there is none to keep: a new agent, one the script dropped, or a stored agent that has none. They do not warn when the project’s AgentEditorCommons predates 2.2, which does not read the value. mxcli lint reports stored agents without one as MPR014.

MDL-AGENT03: A consumed MCP service without an endpoint

consumed mcp service Shop.CarrierTracking has no Endpoint: Agent Editor skips it at startup
("No value could be found for the local configuration for the constant"), so it connects
nowhere [MDL-AGENT03]

Cause: Agent Editor imports a consumed MCP service only when its endpoint constant has a value: ConsumedMCPService_CreateUpdateFromModel reads it with GetConstantValue and skips the service otherwise. mxbuild does not read agent documents, so the build is green; the agents that use the service get none of its tools.

Solution: Add Endpoint: @Module.UrlConstant, a String constant holding the server URL, and set its value per environment. create or modify keeps a stored endpoint when the key is left out, so check --references and exec warn on it only when there is none to keep. mxcli lint reports stored services without one as MPR015.

MDL-LISTOP02: A list operation nested inside another one

count(…): the list argument is `filter($reqs, $currentObject/Status = Mod.E.Approved)`,
which is not a variable. A Mendix aggregate list activity stores its list as a
variable reference and has no slot for a nested computation, so the argument is
dropped and the activity is written with an empty list — mxbuild then rejects it
with CE0012 "The 'List' property is required.". [MDL-LISTOP02]

Cause: Every list operation and aggregate — HEAD, TAIL, FIND, FILTER, SORT, UNION, INTERSECT, SUBTRACT, RANGE, COUNT, SUM, AVERAGE, MINIMUM, MAXIMUM, REDUCE, ALL, ANY — is a separate activity in Mendix, and an activity stores its list as a variable reference. MDL’s expression grammar makes them look composable, but there is nowhere in the model to put a nested call.

Before this rule existed the inner call was dropped, list and predicate together, and the activity was written with an empty list. That passes mxcli check, execs with Created microflow, and fails only at build time — CE0012 "The 'List' property is required." for an aggregate, CE0096 for a list operation. sort(filter(…), Attr) was worse: with the list gone the sort attribute has no entity to resolve against, and mxbuild aborts with an InvalidOperationException instead of reporting an error.

The rule keys on the operand not reducing to a variable, so it also covers a non-list argument: count('nonsense') failed the same way.

Solution: Give the inner operation its own statement and pass the variable.

-- WRONG
$n = count(filter($Requests, $currentObject/Status = Module.ENUM_Status.Approved));
-- RIGHT
$Approved = filter $Requests where $currentObject/Status = Module.ENUM_Status.Approved;
$n        = count $Approved;

The same applies to both operands of union/intersect/subtract.

MDL-SET01: set on an object variable

cannot set object variable '$Cursor' (G46.Group): Mendix has no action that reassigns an
object variable — Change variable takes only a primitive, and mxbuild rejects it with
CE7247 "Variable 'Cursor' does not have a primitive type". [MDL-SET01]

Cause: set $Var = … is a Change variable activity, which takes only a primitive variable; on a list it is a Change list Replace. Mendix has no activity that reassigns an object variable, so the statement used to be written as a Change variable and fail the build with CE7247 (mendixlabs/mxcli#1323). It comes up naturally when walking a parent chain with set $Cursor = $Next in a while loop. On a parameter mxbuild words the same code "Parameter 'X' cannot be changed.".

Plain check reports it for the objects it can see without a project — entity parameters, create, retrieve … first, loop iterators, casts, head. Whether an association retrieve is an object or a list depends on the association, so that case is reported by check --references and exec.

Solution: Return the new object from a sub-microflow (recursion for a chain walk), retrieve it into a new variable, or change the object’s members with change $Obj (…).

The same rule refuses set on a parameter of any type but a list, in a microflow, a nanoflow or a rule:

cannot set parameter '$N' (Integer): a Change variable cannot target a parameter, and
mxbuild rejects it with CE7247 "Parameter 'N' cannot be changed". [MDL-SET01]

Copy the parameter into a variable and change that (declare $Value Integer = $N;). A list parameter is not refused — set $L = $M on one is a Change list Replace, which builds — and neither is a member change, set $Param/Attr = ….

MDL-RETRIEVE02: Retrieve by association over System.owner / System.changedBy

retrieve $u from $task/System.owner: System.owner is not a modelled association, and a
retrieve by association over it has no entity — mxbuild rejects it with CE0136 "Retrieve
object must specify the 'Entity' property." [MDL-RETRIEVE02]

Cause: owner and changedBy are not associations in any domain model — an entity carries them as the Store owner / Store changedBy flags (AutoOwner, AutoChangedBy). XPath constraints and page paths resolve them, but a retrieve by association does not: mxbuild finds the name and derives no entity from it, whether or not the entity stores the member (measured on 11.12.2, mendixlabs/mxcli#1358). check and exec both refuse it.

Solution: Retrieve the user from the database by id:

retrieve $u from System.User where [id = $task/System.owner] first;
retrieve $u from System.User where [id = $task/System.changedBy] first;

That constraint reads the member off $task, so it builds only when $task’s entity stores it — owner: AutoOwner / changedBy: AutoChangedBy on the root of its generalization chain (a System.FileDocument specialization stores both). On an entity that does not, mxbuild rejects it with CE0161 “Error(s) in XPath constraint.” (measured on 11.12.5, ako/mxcli#1306), and check -p and exec refuse it, naming the alter entity that stores the member.

MDL-CHANGE01: Setting System.owner / System.changedBy in a create or change

member "System.owner" on M.Entity: System.owner is kept by the runtime, not set by a
microflow — a create or change object cannot set it, and mxbuild rejects the member with
CE1050 "Unknown association 'System.owner'." (whether or not the entity stores it) [MDL-CHANGE01]

Cause: The owner and last editor of an object are system members, not associations a project models. Written as a create or change object member, System.owner and System.changedBy are CE1050 for every entity — one that stores its owner, one that does not, a System.FileDocument specialization — in a microflow and a nanoflow alike (measured on 11.12.5, mendixlabs/mxcli#1612). check needs no project to know it; check and exec both refuse it. A list operation is different: find $l by System.owner = $u (and filter, and System.changedBy) builds when the list’s entity stores the member — on one that does not it is CE0091 “No member selected.”, which check -p and exec report with the alter entity that stores it (ako/mxcli#1629).

Solution: To record a user on an object — for example to own seeded data — model the association and set that:

create association M.Entity_User from M.Entity to System.User;

$S = create M.Entity (Name = 'x', M.Entity_User = $User);

Constrain on M.Entity_User where you would have used System.owner.

MDL-RETRIEVE03: change over a list retrieve

$E was retrieved as a list (under `mdl 1;` `limit 1` is a list of one), not a single
object, so `change $E` cannot take it — mxbuild rejects the stored member with CE1613
"The selected attribute 'List of …' no longer exists". [MDL-RETRIEVE03]

Cause: The reverse of MDL-RETRIEVE01. A database retrieve with no range, with limit n [offset n], or — under the mdl 1; header — with a bare limit 1 binds a list; a Change object activity takes one object. Before mendixlabs/mxcli#1422, exec wrote such a change with the member qualified through the list type, which mx check reported as CE1613 (measured on 11.6.6). A member path $E/Attr in any later expression is the same mistake and is CE0117 “Error(s) in expression.”. check and exec both refuse both. set $E = empty over the list is a Change list Replace and builds, so it is not reported.

Solution: Retrieve the object with first, or change the items inside a loop:

retrieve $E from Mod.Ent first;
change $E (Attr = 'x');

Over an association (check --references, mendixlabs/mxcli#1516). Whether retrieve $L from $h/Mod.Assoc binds a list depends on the association: from the FROM entity of a Reference it is one object; from its TO entity (the “one” side) it is a list, unless the owner is Both; a ReferenceSet is a list from either end. The project knows which, so with -p / --references a member path over that list (CE0117), a change of it (CE7247 “Variable ‘L’ is of type List of …, but should be of type Object.”), and passing it to a parameter that takes one object (CE0117) are reported too — measured identically on 11.6.6 and 11.15.0. The bare list retrieve, count, a loop and a list parameter build. For one object, retrieve it from the database:

retrieve $L from Mod.Line where [Mod.Line_Header = $h] first;

The reverse is reported as MDL-RETRIEVE01 (ako/mxcli#1793): the one object a Reference gives from its FROM entity, taken as a list. An aggregate of it (count, sum, …) is CE0013 “Input variable ‘P’ must be of type ‘List’.”, a list operation (head, filter, …) CE0097, a loop CE0100 and a list parameter CE0117 — measured on 11.13.0. A self-reference (Mod.Order_Parent from Mod.Order to Mod.Order) is always followed from its FROM end, from an Order and from a specialization of it alike: a Reference gives the one object, a Reference set the list. range, add, remove and clear over the object are not reported — mx check does not type their variable.

$P = retrieve over Mod.Order_Parent from $Order (a Mod.Order) is one Mod.Order — a
Reference followed from its FROM end, which is how Mendix follows a self-reference — so
`count` cannot take it: mxbuild rejects it with CE0013 "Input variable 'P' must be of
type 'List'." [MDL-RETRIEVE01]

A flow already stored with such a member describes with a -- WARNING on the change line. create or modify will not splice over it: without the header it rebuilds the flow (which repairs it), and under mdl 1; it refuses with the drop-and-create advice.

MDL-EMAIL02 / 03: A send email setting Studio Pro would not allow

send email: CheckServerIdentity has no effect without SecurityType: ssl (it is TLS) [MDL-EMAIL02]
send email: header name "Bad Name!" may contain only letters, digits and hyphens [MDL-EMAIL03]

Cause: Studio Pro enables Check Server Identity only for SSL (MDL-EMAIL02), and restricts a custom header to a name of letters, digits and hyphens and a single-line, non-empty value (MDL-EMAIL03). mxbuild 11.15 accepts both, which is why they are warnings: a script is the only way to store them, and the flag is ignored or the header fails only when a mail is sent.

Solution: SecurityType: ssl, CheckServerIdentity: true, or drop CheckServerIdentity; rename the header, e.g. Headers: ('X-Correlation-Id': 'value').

MDL-TEMPLATE01: A message written as an expression followed by with (…)

log: the message is an expression, so `with (…)` has no template to fill — the
parameters fill the {n} placeholders of a message written as a string. [MDL-TEMPLATE01]

Cause: The text of a log message, a send email subject or body, a show message and a validation feedback message is a template: a string whose {n} placeholders with (…) fills. An expression is the whole text, stored as the template '{1}' with the expression as its parameter, so it has no placeholder for further parameters, and Mendix cannot store an expression as a template’s text. Before ako/mxcli#1683, log and send email stored the expression’s source as the template — the log line read $Msg — and show message / validation feedback stored the parameters where no placeholder read them. check, exec and mx check passed all four. check and exec now refuse it with one shared function, as MDL-REST05 does for call rest service.

Solution: Write the template as a string, or the expression without with (…):

log info node 'Orders' 'Order {1} shipped' with ({1} = $Order/Number);
log info node 'Orders' $Msg;

MDL-TEMPLATE02: A template’s braces that MxBuild refuses

log: the message template has `{number}` — MxBuild rejects it (CE0712 "Brace should be
followed by a number of digits or another brace.") [MDL-TEMPLATE02]

Cause: Every Mendix text template — a log, show message or validation feedback message, a send email subject or body, a call rest service URL or body, a Java action’s text-template argument, a workflow’s display, description and task texts, a dynamic text’s content and a button’s caption — reads its text with one grammar. A { opens a numbered placeholder ({1}, {2}, …) or is escaped as {{; a } needs no escape. Anything else is a build error, with the code check reports in its message:

TextMxBuild
{number}, {}, { 1 }, {-1}, {"a": 1}CE0712
{1 at the end of the textCE0713
{1x}, {1 }CE0714
{0}CE0716
{2147483648} (past 32 bits)CE0717
{2} with one parameterCE0720

Measured on mxbuild 10.24.24, 11.13.0 and 11.15.0, the same in every slot (ako/mxcli#1397). check and exec passed all of them before; exec now refuses a flow with one, --no-check included.

Solution: Number the placeholders and fill them with with (…); escape a literal brace:

log info node 'Orders' 'Item {1} not found' with ({1} = $Number);
$R = call rest service post 'https://example.com/items' (
  Body: template '{{"name": "{1}"}' with ({1} = $Name)
) returns String;

MDL-REGEX01: A regular expression that ends in a lone backslash

return: the regular expression of replaceAll, '\', ends in an unescaped backslash — it
builds, and the call fails each time it runs [MDL-REGEX01]

Cause: The second argument of replaceAll, replaceFirst and isMatch is a regular expression. One that ends in a lone \ builds — MxBuild does not read the pattern — and fails every time it runs: the runtime throws PatternSyntaxException "Unescaped trailing backslash", a nanoflow "\ at end of pattern". A warning, since the build accepts it. The replacement (third argument) is literal text on both sides, so a backslash or $ in it is inserted as written and is never reported.

Solution: Match a backslash with \\ — '\\' under mdl 1;, '\\\\' without the header, where \\ is one backslash. See mxcli syntax microflow expressions.

MDL-JSONNUM01: A locale-dependent number in hand-built JSON

set '$Json' builds JSON with formatDecimal(…) and no locale: it formats in the user's
language, so a Dutch user gets '12,50' and the JSON is invalid for them only [MDL-JSONNUM01]

Cause: formatDecimal(x, '0.00') without a locale argument formats in the current user’s language. The microflow writes 12.50 for an English user and 12,50 for a Dutch one, so JSON built by concatenation (a chart’s data, an API payload) is invalid only for some users — typically not the author. An underscore locale tag ('nl_NL', 'en_US') is silently ignored and falls back to the user’s language; only the hyphenated form applies. Measured on Mendix 11.13.

The rule is info and heuristic: it fires when the same + concatenation has a string literal containing { or ":. A display string such as 'Total: ' + formatDecimal(…) is not flagged.

Solution: Use toString(round(x, 2)), which always writes a . and never an exponent, or pass a hyphenated locale.

-- WRONG
set $Json = $Json + ',"v":' + formatDecimal($R/Total, '0.00') + '}';
-- RIGHT
set $Json = $Json + ',"v":' + toString(round($R/Total, 2)) + '}';
set $Json = $Json + ',"v":' + formatDecimal($R/Total, '0.00', 'en-US') + '}';

Mismatched input

Line 3, Col 42: mismatched input ')' expecting ','

Cause: Syntax error in the MDL statement – typically a missing comma, semicolon, or unmatched bracket.

Solution: Check the MDL syntax at the reported line and column. Common issues:

  • Missing commas between attribute definitions
  • Missing semicolons at the end of statements
  • Unmatched parentheses or curly braces

No viable alternative

Line 1, Col 0: no viable alternative at input 'CREAT'

Cause: Unrecognized keyword or misspelling.

Solution: Check the keyword spelling. MDL keywords are case-insensitive but must be valid. Run mxcli syntax keywords for the full keyword list.

mxcli Execution Errors

Module not found

Error: module 'MyModule' not found in project

Cause: The referenced module does not exist in the .mpr file.

Solution: Check the module name with LIST MODULES and verify the spelling. Module names are case-sensitive.

Entity not found

Error: entity 'MyModule.Customer' not found

Cause: The referenced entity does not exist in the specified module.

Solution: Check with LIST ENTITIES IN MyModule. If the entity was just created, ensure the create statement executed successfully before referencing it.

Reference validation failed

Error: unresolved reference 'MyModule.NonExistent' at line 5

Cause: A qualified name references an element that does not exist in the project. This error appears with mxcli check script.mdl -p app.mpr --references.

Solution: Verify the referenced element exists, or create it before the referencing statement.

Bare name is not a member of the list’s entity

Error: filter($L, …): "Nonexistent" is not an attribute or association of
Shop.Order — a filter predicate is evaluated once per item, so a bare name must
be a member of the list's entity (mxbuild reports CE0117 otherwise)

Cause: A bare name in a FILTER/FIND predicate can only mean a member of the item being tested. mxcli resolves it against the list’s element entity and rewrites it to $currentObject/<member>; a name that does not resolve cannot be rewritten into anything valid.

Solution: Check the spelling against DESCRIBE ENTITY Shop.Order. If you meant a variable rather than a member, write it with its $ — a bare word is always read as a member of the iterated item.

Before mxcli qualified these, the unresolvable name was written into the expression verbatim and surfaced at build time as CE0117 "Error(s) in expression.", with mxcli check reporting nothing.

BSON Serialization Errors

Wrong array prefix

Symptom: Studio Pro fails to load the project or shows garbled data.

Cause: Missing or incorrect integer prefix in BSON arrays. Mendix BSON arrays require a count/type prefix as the first element:

{
  "Attributes": [3, { ... }, { ... }]
}

Solution: Ensure all arrays include the correct prefix value (typically 2 or 3). Check existing BSON output for the correct prefix for each array property.

Wrong reference format

Symptom: Studio Pro crashes or shows null reference errors.

Cause: Using BY_ID_REFERENCE (binary UUID) where BY_NAME_REFERENCE (qualified string) is expected, or vice versa.

Solution: Check the metamodel reflection data for the property’s kind field:

  • BY_ID_REFERENCE -> use binary UUID
  • BY_NAME_REFERENCE -> use qualified name string (e.g., "Module.Entity.Attribute")

Glossary

Mendix-specific terms and concepts for developers who are new to the Mendix platform.

A

Association A relationship between two entities. Analogous to a foreign key in relational databases. Mendix supports Reference (one-to-many or one-to-one) and ReferenceSet (many-to-many) types.

Attribute A property of an entity. Equivalent to a column in a relational database table. Types include String, Integer, Long, Decimal, Boolean, DateTime, AutoNumber, Binary, Enumeration, and HashedString.

Access Rule A security rule that controls which module roles can create, read, write, or delete instances of an entity, and which attributes they can access.

B

BSON Binary JSON. The serialization format Mendix uses to store model elements inside MPR files. Each document (entity, microflow, page, etc.) is stored as a BSON blob.

Building Block A reusable page fragment that can be dragged onto pages in Studio Pro. Similar to a Snippet but intended for the toolbox.

C

Catalog In mxcli, an in-memory SQLite database that indexes project metadata for fast querying, full-text search, and cross-reference navigation.

Constant A named value defined at the module level that can be configured per environment (development, acceptance, production). Used for API keys, URLs, feature flags, etc.

D

DataGrid A widget that displays a list of objects in a tabular format with columns, sorting, and search. The classic DataGrid uses server-side rendering; DataGrid2 is the pluggable widget version.

DataView A widget that displays a single object, providing a context for input widgets (TextBox, DatePicker, etc.) to read and write attributes.

Domain Model The data model for a module, consisting of entities, their attributes, and associations between them. Analogous to an entity-relationship diagram.

E

Entity A data type in the domain model. Equivalent to a table in a relational database. Entities can be persistent (stored in the database), non-persistent (in-memory only), or view (backed by an OQL query).

Enumeration A fixed set of named values (e.g., OrderStatus with values Draft, Active, Closed). Used as attribute types when the set of possible values is known at design time.

Event Handler Logic that runs automatically before or after a commit or delete operation on an entity. Configured on the entity in the domain model.

G

Gallery A pluggable widget that displays a list of objects in a card/tile layout, as opposed to the tabular layout of a DataGrid.

Generalization Entity inheritance. When entity B generalizes entity A, B inherits all of A’s attributes and associations. Analogous to class inheritance in object-oriented programming.

L

Layout A page template that defines the common structure (header, sidebar, footer) shared by multiple pages. Every page references a layout.

LayoutGrid A responsive grid widget based on a 12-column system. Used to create multi-column layouts with breakpoints for phone, tablet, and desktop.

M

MDL (Mendix Definition Language) A SQL-like text language for querying and modifying Mendix projects. The primary interface for mxcli.

Microflow Server-side logic expressed as a visual flow of activities. Microflows can retrieve data, call actions, make decisions, loop over lists, and return values. They run on the Mendix runtime (server).

Module A top-level organizational unit in a Mendix project. Each module contains its own domain model, microflows, pages, enumerations, and security settings. Analogous to a package or namespace.

Module Role A permission role defined within a module. Module roles are assigned to user roles at the project level. Entity access rules, microflow access, and page access are granted to module roles.

MPR Mendix Project Resource file. The binary file (.mpr) that contains the complete Mendix project model. It is a SQLite database with BSON-encoded documents.

N

Nanoflow Client-side logic that runs in the browser (or native app). Syntactically similar to microflows but with restrictions (no database transactions, limited activity types). Used for offline-capable and low-latency operations.

P

Page A user interface screen in a Mendix application. Pages contain widgets (TextBox, DataGrid, Button, etc.) and reference a layout for their outer structure.

Pluggable Widget A widget built with the Mendix Pluggable Widget API (React-based). Examples: DataGrid2, ComboBox, Gallery. Pluggable widgets use JSON-based property schemas (PropertyTypes) stored in the MPR.

R

Runtime The Mendix server-side execution environment (Java-based) that runs the application. It interprets the model, handles HTTP requests, executes microflows, and manages the database.

S

Scheduled Event A microflow that runs automatically on a timer (e.g., every hour, daily at midnight). Configured with a start time and interval.

Snippet A reusable UI fragment that can be embedded in multiple pages via a SnippetCall widget. Snippets can accept parameters.

Studio Pro The visual IDE for building Mendix applications. It reads and writes .mpr files. mxcli provides a complementary text-based interface to the same project files.

Storage Name The internal type identifier used in BSON $Type fields. Often the same as the qualified name, but not always (e.g., DomainModels$EntityImpl is the storage name for DomainModels$Entity).

U

User Role A project-level role that aggregates module roles from one or more modules. End users are assigned user roles, which determine their permissions across the entire application.

W

Widget A UI component on a page. Built-in widgets include TextBox, DataGrid, Button, Container, and LayoutGrid. Pluggable widgets extend this set with third-party components.

Workflow A long-running process with user tasks, decisions, and parallel paths. Workflows model approval processes, multi-step procedures, and human-in-the-loop automation.

TypeScript SDK Equivalence

Mapping between the official Mendix Model SDK (TypeScript) and MDL/modelsdk-go equivalents.

Overview

The Mendix Model SDK is Mendix’s official TypeScript library for programmatic model manipulation. It works through the Mendix Platform API and requires cloud connectivity. MDL and modelsdk-go provide similar capabilities but operate directly on local .mpr files.

Feature Comparison

FeatureMendix Model SDK (TypeScript)MDL / modelsdk-go
LanguageTypeScript / JavaScriptGo (library), MDL (DSL)
RuntimeNode.jsNative binary
Cloud requiredYes (Platform API)No (local files)
AuthenticationAPI key + PATNone
Real-time collaborationYesNo
Read operationsYesYes
Write operationsYesYes
Type safetyTypeScript typesGo types
CLI toolNoYes (mxcli)
SQL-like DSLNoYes (MDL)
AI assistant integrationLimitedBuilt-in (multi-tool)
Full-text searchNoYes (FTS5)
Cross-reference analysisNoYes (callers, impact)
LintingNoYes (Go + Starlark rules)

Operation Mapping

Reading

TypeScript SDKMDL EquivalentGo Library
model.allDomainModels()LIST ENTITIESreader.ListDomainModels()
domainModel.entitiesLIST ENTITIES IN Modulereader.GetDomainModel(id)
entity.attributesDESCRIBE ENTITY Module.Namedm.Entities[i].Attributes
model.allMicroflows()LIST MICROFLOWSreader.ListMicroflows()
model.allPages()LIST PAGESreader.ListPages()
model.allEnumerations()LIST ENUMERATIONSreader.ListEnumerations()

Writing

TypeScript SDKMDL EquivalentGo Library
domainmodels.Entity.createIn(dm)CREATE PERSISTENT ENTITY Module.Name (...)writer.CreateEntity(dmID, entity)
entity.name = "Foo"Part of CREATE ENTITYentity.Name = "Foo"
domainmodels.Attribute.createIn(entity)Column in entity definitionwriter.AddAttribute(dmID, entityID, attr)
domainmodels.Association.createIn(dm)CREATE ASSOCIATIONwriter.CreateAssociation(dmID, assoc)
microflows.Microflow.createIn(folder)CREATE MICROFLOWwriter.CreateMicroflow(mf)
pages.Page.createIn(folder)CREATE PAGEwriter.CreatePage(page)
enumerations.Enumeration.createIn(module)CREATE ENUMERATIONwriter.CreateEnumeration(enum)

Security

TypeScript SDKMDL Equivalent
security.ModuleRole.createIn(module)CREATE MODULE ROLE Module.RoleName
Manual access rule constructionGRANT role ON Entity (permissions)
Manual user role creationCREATE USER ROLE Name ( ModuleRoles: (ModuleRoles) )

Workflow Comparison

TypeScript SDK Workflow

const client = new MendixPlatformClient();
const app = await client.getApp("app-id");
const workingCopy = await app.createTemporaryWorkingCopy("main");
const model = await workingCopy.openModel();

const dm = model.allDomainModels().filter(d => d.containerAsModule.name === "Sales")[0];
await dm.load();

const entity = domainmodels.Entity.createIn(dm);
entity.name = "Customer";

const attr = domainmodels.Attribute.createIn(entity);
attr.name = "Name";
attr.type = domainmodels.StringAttributeType.create(model);

await model.flushChanges();
await workingCopy.commitToRepository("main");

MDL Equivalent

CREATE PERSISTENT ENTITY Sales.Customer (
    Name: String(200)
);

Go Library Equivalent

reader, _ := modelsdk.Open("app.mpr")
modules, _ := reader.ListModules()
dm, _ := reader.GetDomainModel(modules[0].ID)

writer, _ := modelsdk.OpenForWriting("app.mpr")
entity := modelsdk.NewEntity("Customer")
entity.Attributes = append(entity.Attributes,
    modelsdk.NewStringAttribute("Name", 200))
writer.CreateEntity(dm.ID, entity)

Key Differences

  1. No cloud dependency – modelsdk-go works entirely offline with local .mpr files
  2. No working copy management – changes are written directly to the file (back up first)
  3. MDL is declarative – one statement replaces multiple imperative SDK calls
  4. Token efficiency – MDL uses 5-10x fewer tokens than equivalent JSON model representations, making it better suited for AI assistant context windows
  5. Built-in search and analysis – full-text search, cross-reference tracking, and impact analysis are not available in the TypeScript SDK

Changelog

Version history and release notes for ModelSDK Go and the mxcli tool.

Releases

See GitHub Releases for the complete version history, including:

  • Release notes with feature descriptions
  • Pre-built binaries for all platforms (Linux, macOS, Windows – amd64 and arm64)
  • Source code archives

Versioning

mxcli follows Semantic Versioning:

  • Major – breaking changes to the MDL language or Go library API
  • Minor – new features and capabilities (backward-compatible)
  • Patch – bug fixes and documentation updates

Status

Alpha-quality software. This project is in early stages. Expect bugs, missing features, and rough edges. Always back up your Mendix project files before using mxcli to modify them.

Blog

News, release announcements, and demos from the mxcli team.


DatePost
November 9, 2026Release: mxcli v0.31.0
November 2, 2026Release: mxcli v0.30.0
October 30, 2026Release: mxcli v0.29.0
October 23, 2026Release: mxcli v0.28.0
October 15, 2026Release: mxcli v0.27.0
October 11, 2026Release: mxcli v0.26.0
April 23, 2026Introducing mxcli

Release: mxcli v0.31.0

November 9, 2026

v0.31.0 is about publishing your app’s microflows as an MCP server, and keeping secrets off the command line.

What’s new

Publish microflows as an MCP server. create published mcp service writes the MCP Server 5.x document that Agent Editor starts when the app starts. Each tool block exposes one microflow to external agents:

create published mcp service AgentTest.CustomerMCP (
  EndpointPath: 'customers',
  Version: '1.0.0',
  ProtocolVersion: v2025_03_26
) {
  tool count_customers (
    Microflow: AgentTest.MCPTool_CountCustomers,
    Description: 'Count the customers whose name starts with NamePrefix.',
    Title: 'Count customers'
  )
};

The server is served at <app URL>/customers/mcp. alter … add tool / drop tool, describe, list published mcp services and drop complete the set, and the OAuth settings are there too. check reports what the server would refuse at startup: a reserved endpoint path, scopes without an authorization server, or a tool without a microflow. For MCP Server 3.x–4.x, the new mcp-server skill builds the same server from startup microflows.

Agents: tool approval and streaming. Agent Editor’s human-in-the-loop settings now have MDL. A tool can be hidden, shown, or require the user’s confirmation (UserAccessApproval), with its own display title and description. Backend and UI streaming are switches on the agent. check also warns about a model or knowledge base Provider that Agent Editor would fail to import at startup.

Secrets stay off the command line. The run password flags (--db-password, --db-admin-password, --screenshot-password, --hub-secret, --debug-pass) and --constant accept @env:VAR and @file:path:

mxcli run --local -p app.mpr \
  --db-password @env:PGPASSWORD \
  --constant MyModule.ApiKey=@file:$HOME/.secrets/api-key

ps shows the reference, and the run state stores the reference, not the value. A detached run can still be restarted with run restart, because each start reads the secret again. A file that its group or others can read is refused, so chmod 600 it first.

A brain from day one. A brain can now be started before its project exists (-C <dir>), and mxcli new adopts it. Entries can be edited and moved without changing their id, can record the document and lines they came from (--source), and brain list --json returns the committed store as data for tools to read.

More of the model from MDL. This release adds:

  • send external object and delete external object, so a flow that holds them survives a describe-and-recreate.
  • Associations to include on a call external action argument.
  • A module’s version and module type.
  • A queued call’s retry policy.
  • alter page set for a Data grid 2 column’s attribute and for a text template’s parameters.

check catches more before the build. A case difference in an image reference, a drop or alter of a document that doesn’t exist, and many smaller cases that exec or mxbuild would refuse are now reported by check --references. mxcli test --local starts every run on an empty test database, recovers when mxbuild --serve stops answering, and repeats a failed build’s errors above the summary line.

Upgrading

Download the v0.31.0 binary for your platform from the release page:

curl -fsSL -o mxcli \
  https://github.com/mendixlabs/mxcli/releases/download/v0.31.0/mxcli-linux-amd64
chmod +x mxcli && sudo mv mxcli /usr/local/bin/

Then refresh a project’s skills and commands with mxcli init --sync-skills.

Behaviour changes worth knowing:

  • A --constant value or a secret run flag that starts with @ is refused unless it is @env:, @file: or @@. Write @@… for a value that really starts with @.
  • Run state (.mxcli/run-state.json and friends) no longer stores secrets, and an app folder you copy no longer inherits the original’s run state.
  • check --references matches image names case-sensitively, as Mendix does. A script that relied on the old case-insensitive match now gets an error that names the stored spelling.
  • The catalog schema moves to version 34, so a cached catalog is rebuilt once.

Full changelog: CHANGELOG.md

Release: mxcli v0.30.0

November 2, 2026

v0.30.0 is about catching before the build what would fail at build time or in production, and merging Mendix models from the command line.

What’s new

Merge branches outside Studio Pro. git merge can’t merge a Mendix model. The .mpr is SQLite and every .mxunit is BSON, so two branches that both change the model always conflict. mxcli git merge <ref> runs Studio Pro’s own merge engine (mx merge), which also works in a Linux devcontainer:

mxcli git merge feature/approvals -p app.mpr

Whatever the engine resolves is committed as a normal merge commit with Studio Pro’s mx_metadata note. If the engine needs a decision, nothing lands: the merge is aborted, both sides’ changes are printed unit by unit, and a plan file is written next to the project. Pick ours or theirs per unit in the plan and re-run with --plan <file>. Or take one side of every conflict with --ours / --theirs, or open the merge in Studio Pro for a mixed resolution.

Security errors before the build, and before production. Two new lint rules cover access mistakes:

  • SEC012 reports mxbuild’s CE2729 ahead of the build: a page a user role can reach that shows an attribute, or lists an entity, the role may not read. On a test project with a role that has no access, it finds the same 27 errors mxbuild does.
  • SEC011 catches a mistake that nothing else does. A page’s data source XPath crosses an association the page’s roles can’t read, so at Production security the page opens to an error dialog, while mxbuild, check and lint were all clean. It reports the member to grant, and names the widget.

MPR012 now reports every widget measured as unsupported under the React client (CE0582), with its replacement. A new rule, CONV021, merges a create or change followed directly by its commit into one activity. And check warns on trim($Value) != empty, which is always true, because String functions return '' and never empty (E015).

Lint a legacy project without the noise. mxcli lint --baseline lint-baseline.json --update-baseline records today’s findings. Commit the file, and from then on mxcli lint --baseline lint-baseline.json reports only new findings, with an exit code that follows them alone. Custom lint rules can also read more of the model, including access rules, enumeration captions, constant values and project settings.

Optional page parameters. Params: ( $Sale: Shop.Sale optional ) is Studio Pro’s unticked Required (Mendix 11.5+): the page can be opened without an argument, from a menu item for example.

Lay out flows your way. A committed layout-config.yaml chooses how create microflow and mxcli layout flows place what a script leaves unpositioned. Pick the happy-line default or true-right-false-down, and options such as ELSE as a column under its decision, guard clauses to the right, and a maximum row width.

--mcp uses Studio Pro alone. The optional Concord server is gone. --mcp-check runs Studio Pro’s project-wide error check (11.15+), --mcp-run starts the app and prints its URL (11.16+), and DROP uses Studio Pro 11.16’s native delete. --mcp http://localhost:<port>/mcp now also reaches Studio Pro on a Windows machine running Docker Desktop.

Faster on large projects. A catalog refresh after a save rebuilds only what the save changed, and no longer re-reads every widget package. describe decodes the one document it shows instead of every document of its type.

Upgrading

Download the v0.30.0 binary for your platform from the release page:

curl -fsSL -o mxcli \
  https://github.com/mendixlabs/mxcli/releases/download/v0.30.0/mxcli-linux-amd64
chmod +x mxcli && sudo mv mxcli /usr/local/bin/

Then refresh a project’s skills, commands and the companion mod with mxcli init --sync-skills. Every mxcli init --tool <x> now installs the bundled lint rules, and lint tells you when it runs without them.

Behaviour changes worth knowing:

  • The Concord MCP flags --mcp-concord, --mcp-concord-dial and --mcp-save are removed. Studio Pro writes each change to disk as it lands, so there is nothing left to save.
  • Integer properties are written as 64-bit integers, as Studio Pro writes them. Documents an older mxcli wrote are not migrated, and reads accept either width.
  • The catalog schema moves to version 32, so a cached catalog is rebuilt once. The first command on a project after upgrading also builds the unit index, and now says so.
  • git doctor no longer reports or writes the mendix.* git config keys, which are Studio Pro’s own bookkeeping.

Full changelog: CHANGELOG.md

Release: mxcli v0.29.0

October 30, 2026

v0.29.0 is about writing what your project’s own Mendix version stores, and committing it the way Studio Pro does. A statement your Mendix 9 or 10 project can’t store is now refused at check time, before it can leave you with a project Studio Pro won’t open. And a team that commits with plain git can keep its history in the shape Studio Pro expects.

What’s new

Commit the way Studio Pro does. Studio Pro’s Commit does more than git commit: it keeps the contents hashes in step, leaves out the files a Mendix repository never ships, and attaches the mx_metadata note it uses for its version check and history. mxcli git commit does all of that in one line:

mxcli git commit -m "Add order approval" -p app.mpr --push

--push pushes the note along with the branch. --dry-run shows what would be committed without writing anything. The commit is refused while Studio Pro has the project open, or while a merge is unresolved.

Audit a repository. mxcli git doctor reports what a plain-git history has drifted on: missing local git config, an SSH remote Studio Pro can’t use, commits without a note, and notes Studio Pro can’t read. It exits 1 while an issue remains, so it can gate CI. --fix repairs them.

Your Mendix version decides what gets written. mxcli now knows which element types and property shapes each Mendix version has, measured against Studio Pro. Workflows, aggregates, constant overrides and $currentObject arguments are written in the shape Mendix 9 and early 10.x expect. Before, they were refused or left the project unloadable. A statement the version can’t store, such as notify workflow on 9.x or a loop feature from a later release, now fails check -p with MDL-VERSION01 and names the clause. alter microflow is gated the same way as create.

Workflows say more. A user task takes a task name. Workflow and task texts take template parameters (display 'Approval for {1}' with ({1} = $WorkflowContext/Title)), and a timer boundary event takes a caption. A non-interrupting timer can repeat, with repeat every 1 day at most 3 times after its delay.

A new warning, MDL-WF20, catches a user task whose page has no way to complete one of its outcomes. That mistake passes the build and leaves every instance waiting at the task.

Edit inside a loop. alter microflow and create or modify now insert, drop or move an activity inside a loop body, where they used to refuse. If you want the old behaviour, replace body of <loop> replaces just the body, keeping the loop’s IDs.

Fewer settings lost on a rewrite. A page’s own design properties, a native text style, a tab page’s badge, a text area’s line count, a layout’s sidebar mode, a read-only data view, and return value mappings on a button’s flow call now survive describe → exec. You can also write them in MDL. Java actions get an extra code clause. And alter app security (AdminPassword: '…') clears CE0129 without opening Studio Pro.

Faster on large projects. On an MPR v2 app with 4,753 units, show modules used to open 4,511 files and describe page 3,176, on every run. Each command now opens only the files it needs. A catalog refresh after a save reads one file or none, and a source-index rebuild describes only what changed.

The debugger works end to end. mxcli debug break sets the breakpoint, continue resumes the paused flow, and inspect prints a String or enumeration value. When the runtime refuses a request, the command now fails with the runtime’s reason instead of reporting success.

check catches more of what mx check would refuse. Examples are a property the widget doesn’t take (Caption: on a text box), a member the entity lacks, a duplicate member name on an inheritance chain, an unknown [%UserRole_X%], and Java action types that can’t be stored. Renames and moves of constants, enumerations and attributes now update the expressions and XPath constraints that refer to them. diff-local also shows every change, settings and grants included.

Upgrading

Download the v0.29.0 binary for your platform from the release page:

curl -fsSL -o mxcli \
  https://github.com/mendixlabs/mxcli/releases/download/v0.29.0/mxcli-linux-amd64
chmod +x mxcli && sudo mv mxcli /usr/local/bin/

Then refresh a project’s skills, commands and the companion mod with mxcli init --sync-skills.

Behaviour changes worth knowing:

  • docker check now checks the project as stored. Before, it ran mx update-widgets on a copy first, so a project with stale widgets got “0 errors” and was then refused by run --local and the build. Stale widgets now fail the check, and each CE0463 that mxcli fix widgets would clear is labelled as such.
  • check -p refuses more: anything your project’s Mendix version can’t store, and several mistakes it used to pass that mx check then rejected. A script that passed before may need a fix, but it would not have built.

Full changelog: CHANGELOG.md

Release: mxcli v0.28.0

October 23, 2026

v0.28.0 is about changing one thing without restating the rest. You can now edit a menu item, a REST operation or a single widget in place, and a flow can take a reworded note or decision caption without being rebuilt.

What’s new

Edit menus in place. Adding one menu item used to mean restating the whole menu. Now:

alter navigation Responsive {
  set (Caption: 'People') on 'Admin' > 'Users';
  drop 'Old';
};

Items are addressed by their caption path, and mxcli never guesses. An address that matches nothing or several items is refused, and the message lists the candidates. alter menu does the same for menu documents. alter consumed rest service adds, changes and removes operations without restating the service.

ALTER PAGE reaches every widget. You can now address legacy table cells by position (table1 cell(1,2)), Accordion groups and other pluggable widget items (acc1 item@2, acc1 item@1.headercontent), and named slots such as dataGrid1.controlbar. Rewriting a page, snippet or layout also no longer deletes the widgets MDL can’t author.

Flows change in place. Under mdl 1, create or modify now splices a reworded note, a changed decision caption, or a change to an activity with a custom error handler, where it used to refuse them as a rebuild.

Protected add-on modules. check -p --references now catches the Hidden-element references that mxbuild refuses (CE1406, CE5400). Entities, attributes, associations and enumerations take export level api|hidden.

Marketplace modules out. mxcli marketplace package MyModule exports a module as a .mpk. marketplace install now runs the widget and design-property repairs itself.

The local dev loop. run --local passes your configuration’s JVM options and starts with scheduled events running. --listen-addr makes the app reachable from another device, and --db-admin-user handles password-protected PostgreSQL. --watch also recovers from two mxbuild --serve faults that left pages returning 404 or a fixed theme still failing.

Security. The deprecated v1 BSON driver is gone, so mxcli now runs on mongo-driver/v2 alone, and a test stops it from coming back. Go moves to 1.27.3, and the npm advisories in the bundled widget and the VS Code extension are cleared.

Upgrading

Download the v0.28.0 binary for your platform from the release page:

curl -fsSL -o mxcli \
  https://github.com/mendixlabs/mxcli/releases/download/v0.28.0/mxcli-linux-amd64
chmod +x mxcli && sudo mv mxcli /usr/local/bin/

Then refresh a project’s skills, commands and the companion mod with mxcli init --sync-skills. It now keeps a bundled lint rule you edited, and doesn’t recreate one you renamed.

Behaviour changes worth knowing:

  • check -p now applies the same Mendix version gates as exec, so a statement your project’s version can’t store fails at check time instead of halfway through exec.
  • run --local now runs scheduled events. Pass --runtime-setting ScheduledEventExecution=NONE to keep them off.

Full changelog: CHANGELOG.md

Release: mxcli v0.27.0

October 15, 2026

v0.27.0 makes exec all or nothing. If a statement fails at write time, the whole run is rolled back, so a script never leaves a project half-changed. Around that sits a large wave of round-trip fixes: describing real apps, checking the output and executing it again now works far more often.

The language poster

New to mxcli? The poster puts all of mxcli and MDL on one page.

The mxcli language poster

Open the interactive poster. Zoom into any panel, or step through it as a 19-panel tour.

What’s new

All-or-nothing exec. Before, a statement that failed at write time left every statement before it in place: a created module stayed, and a drop ahead of the failing create was not undone. Now mxcli backs up the .mpr and every unit file a run touches before the first write, and restores them if anything fails. The message reads “Nothing was written: statement N was rolled back”.

Round trips over real apps. We ran describe → check → exec over complete applications: the Siemens FSM template, the GenAI showcase and Marketplace modules. Every place where the description didn’t check, or didn’t execute back to the same model, is now fixed. That covers indexes on system date members, layouts built on other layouts, Mendix 9 pluggable data sources, decisions on enumeration rules, doc comments with bullets, mutually recursive microflows, and more. describe module --all now emits every document type.

Mendix 9 projects. Microflows, nanoflows and pages with pluggable widgets can be written to 9.x projects again. run --local picks JDK 17 for them.

check -p catches more before exec does. It now checks access rule XPaths, event handler microflows, enumeration keys, view entity sources, DataGrid 2 column attributes, ALTER PAGE targets, and references to image collections and flows the same script creates.

New in MDL. The classic filemanager widget, role-based visibility with Visible: roles (Mod.A, …), and += / -= for reference sets inside change. ALTER PAGE can now address navigation list items (nav item('Home')) and widgets inside Accordion groups.

The domain model, drawn. mxcli describe --format svg domainmodel <Module> draws a module’s domain model as SVG, with entities where Studio Pro shows them and boxes sized to fit every name. The Claude Code companion shows the diagram when you select a Domain Model node, with pan, fit and a full-width view.

Security. The MongoDB BSON driver is upgraded for the October 2026 advisories, which release scanners flagged on the published binaries.

Upgrading

Download the v0.27.0 binary for your platform from the release page:

curl -fsSL -o mxcli \
  https://github.com/mendixlabs/mxcli/releases/download/v0.27.0/mxcli-linux-amd64
chmod +x mxcli && sudo mv mxcli /usr/local/bin/

Then refresh a project’s skills, commands and the companion mod with mxcli init --sync-skills.

One breaking change: mxcli no longer creates a module role on its own. A new page, microflow or nanoflow in a module with no roles used to create <Module>.User and grant itself to it. Now a new document starts with no allowed roles. If a script grants to <Module>.User, add create module role <Module>.User; before the grant.

Full changelog: CHANGELOG.md

Release: mxcli v0.26.0

October 11, 2026

v0.26.0 makes the documents mxcli writes mergeable in Studio Pro. It also adds a companion Project pane for Claude Code, makes describe → exec keep Studio Pro-authored microflows and pages unchanged, and introduces the mxcli language poster.

The language poster

All of mxcli and MDL on one page: the idea, getting started with the bootstrap prompt, the CLI map, exploring and querying a project, domain models, microflows, pages, security, running and testing, the quality gates, skills, custom widgets, the Marketplace, runtime insight, and working alongside Studio Pro.

The mxcli language poster

Open the interactive poster. Zoom into any panel, or step through it as a 19-panel tour.

What’s new

Documents that survive Studio Pro’s merge. Mendix’s merge and diff engine compares an element’s property names between two revisions and throws when they differ. So when mxcli left out a key, or wrote one the project’s Mendix version doesn’t have, the document became unmergeable the first time Studio Pro saved it, even though mx check, the build and the runtime were all green. Every element mxcli writes now carries exactly the property set Studio Pro writes for that version. The set comes from a table measured with mx convert across Mendix 10.24, 11.6 and 11.12–11.15. For a project an older mxcli already wrote to, run:

mxcli fix property-sets -p app.mpr --dry-run   # see what would change
mxcli fix property-sets -p app.mpr

The mxcli companion. A Claude Code mod that mxcli init installs. /mxcli-project opens a Project pane with the app’s structure and the selected document’s MDL, and it reloads as the agent changes the model.

Round trips that change nothing. describe → exec of Studio Pro-authored microflows now keeps loop back-edges, shared error handlers and dead-end loop branches, which were found on real Marketplace modules. Pages keep their layout grid design properties, alignment and input submit behaviour.

check -p agrees with exec more often. A set of scripts is checked against what the earlier files create. Security statements resolve the roles and documents they name. Several XPath and expression mistakes that only failed at build time are now refused up front.

New in MDL. DateTime not localized, close page n, add $X to $Obj/Module.Assoc, module documentation, and alter app security (AdminUserName: …).

The dev loop. mxcli run --local --detach runs the app as a background service, and mxcli playwright check gives a text verdict for a list of pages in one call. run --local and test --local also work on Windows without Developer Mode, including domain model changes under --watch.

Upgrading

Download the v0.26.0 binary for your platform from the release page:

curl -fsSL -o mxcli \
  https://github.com/mendixlabs/mxcli/releases/download/v0.26.0/mxcli-linux-amd64
chmod +x mxcli && sudo mv mxcli /usr/local/bin/

Then refresh a project’s skills, commands and the companion mod with mxcli init --sync-skills.

Behaviour changes worth knowing:

  • A page or snippet variable’s default is now a bare expression (= true, not = 'true'). The old form still works and warns MDL-DEPR086.
  • A published OData service’s authentication is a property (Authentication: (basic, session)), and call rest service takes its settings as one property list.

Full changelog: CHANGELOG.md

Introducing mxcli