MDL Syntax Validation Skill
SkillFiles & storageLets your agent check MDL files for errors before running or sharing them.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the MDL Syntax Validation Skill skill
About this capability
Validate MDL with `mxcli check` before presenting or executing it, including reference resolution against a project. Use ALWAYS before showing MDL to a user, running `mxcli exec`, or committing a .mdl file, exec refuses exactly what check rejects.
What this skill tells your AI
The instructions your AI receives, as published by mendixlabs/mxcli in .claude/skills/mendix/check-syntax/SKILL.md and read by ahel’s review.
This skill ensures MDL scripts are validated before presenting them to users or executing them.
When to Use This Skill
ALWAYS use this skill before:
- Presenting MDL code to users
- Executing MDL scripts via
mxcli exec - Committing MDL files to version control
exec refuses what check rejects
mxcli exec runs the same semantic checks before writing anything. A script whose
checks report an error is not executed at all — nothing is written — because
exec applies statements one at a time and cannot roll back, so a known-bad
script would leave the model partly updated. Warnings are printed and do not stop
the run.
mxcli exec script.mdl -p app.mpr # checked, then applied
mxcli exec script.mdl -p app.mpr --no-check # applied regardless
This does not replace running check yourself. check is faster, needs no
write connection, and reports the warnings worth reading before you commit to a
run. What the gate guarantees is narrower and still valuable: a script that slips
past you cannot half-apply.
It also does not mean the script is correct. mxcli check validates MDL syntax
and mxcli's own rules; it does not validate the Mendix model. Run
mx check (or mxcli docker check -p app.mpr) after applying a slice.
-p resolves references — there is no separate opt-in
mxcli check script.mdl alone checks syntax and the semantic rules that need no
model. Pass -p and it also resolves every reference — modules, entities,
pages, microflows and icons — against that project. It reaches inside stored
documents where a name can only be answered there: an ALTER PAGE … SET is
dry-run against the page it edits, so a widget the page does not have, or a
property the stored widget does not declare, is reported here rather than
stopping the script partway through exec.
mxcli check script.mdl # syntax + model-free rules
mxcli check script.mdl -p app.mpr # ... and every reference resolved
--references is implied by -p and is kept only so existing scripts keep
working. It used to be required, which meant mxcli check script.mdl -p app.mpr
printed an unqualified Check passed! having resolved nothing — a misspelled
icon or entity sailed through a command that had been handed the project. A run
without a project now says what it did not check, so a pass is never read as
more than it is.
It also reports a name the PROJECT already has
A plain create of a document the project already carries is a check error,
not something to discover at exec time:
statement 4: association already exists in project: Sales.Order_Customer — use CREATE OR MODIFY to update it
The reason it belongs in check is that exec stops at the first one having
already written everything before it. A script whose fourth statement
conflicts leaves three statements' worth of changes in the project and no
fourth — so "run it and see" is not a free experiment. check reports every
conflict in the script before anything is written.
Three spellings say "fine if it already exists", and none is reported:
create or modify, create or replace, and create … if not exists (which
leaves the stored element untouched rather than rewriting it). create module M;
is never reported either — it is a no-op when the module exists, which is what
lets it open every script.
The types covered are the ones exec refuses: entity, enumeration, constant,
association, microflow, nanoflow, rule, page, snippet, java action, javascript
action, workflow, and the integration/agent document types. If you find one that
exec refuses and check does not, that is a bug of exactly the shape
TestEveryCreateDocTypeIsProjectChecked exists to prevent.
It reports what the script REMOVES from the project
create or modify entity is on the list above — it is never a conflict, because
"fine if it already exists" is exactly what it says. What it does not say is
that it rebuilds the entity from the statement, so every member the statement
omits is deleted. Slice an app into ordered scripts and that becomes a real
hazard: an attribute added by a later alter entity — a calculated one whose
microflow does not exist until then is the usual reason — is gone the moment the
earlier script is re-run on its own. Script order is load-bearing, even though
each script is individually idempotent.
check now says so before anything is written, as MDL087:
⚠ applying this script to the project removes 1 member(s) from entity
ServiceCore.LithoSystem that it does not restate: OpenRequestCount
— anything still bound to them (widgets, microflows) fails the build with CE1613
at ServiceCore.LithoSystem
→ … or add them incrementally with 'alter entity ServiceCore.LithoSystem
add attribute <name>: <type>;' in this script; if they are meant to go,
say so with 'alter entity … drop attribute <name>;'
exec prints the same list — but as it applies the statement, by which point the
attribute is gone. Left unreported entirely, the loss surfaces slices later as
CE1613 on whatever still binds it, naming the page, not the script that
removed the attribute (ako/mxcli#562).
Two properties of the rule are worth knowing, because they are what keep it from becoming noise you learn to scroll past:
- It is the NET effect of the whole script, not one statement's. A script
that rebuilds an entity and then adds the members back with
alter entity … add attributeloses nothing and is silent. So the combined slices check clean and slice 01 alone does not, which is precisely the difference that bit. - An explicit removal is not reported.
drop attribute,rename attributeanddrop entitysay what they do. Only a member the project holds, that the script neither restates nor asks to remove, is a warning.
It is a warning: "modify to this shape" is a legitimate intent and check
still exits 0. The defect was the silence, not the behaviour. It also covers the
members that are not attributes — the four audit system fields and an omitted
extends — because those drop the same way.
It resolves MEMBER names too, where it can establish the entity
Resolution does not stop at the entity. An attribute named in a create or
change activity is looked up on that entity and its generalizations, so a
typo is reported by check rather than by mxbuild as CE1613 "The selected attribute '…' no longer exists" a whole build later:
Sales.ACT_Close: Sales.Order has no member "IsArchived" (in change $Order)
— it has OrderNo, Status — mxbuild reports this as CE1613 …
This needs the target's entity to be known, and that is the boundary worth understanding rather than assuming:
| the object comes from | checked? |
|---|---|
a create Module.Entity (…) | yes — the entity is in the statement |
| a microflow/nanoflow parameter | yes |
retrieve $L from Module.Entity | yes |
retrieve $L from $Obj/Module.Assoc | yes, when $Obj is itself typed |
a loop over any of those | yes — the iterator inherits the element type |
anything else (send rest request, response: file as $Doc, …) | no |
Widget positions are resolved too:
- an XPath constraint on a
database from Module.Entitysource — every step is followed, so a bare name must be an attribute of the entity it lands on and aModule.Namestep must be an association or an entity; - a template parameter (
ContentParams/CaptionParams) rooted in a variable. That one needs no project and fires under a baremxcli check, because the answer is in the statement.
The template-parameter rule is narrower than "no $ roots", and the difference
is measured rather than reasoned — the writer strips one prefix on one branch:
{1} = … | |
|---|---|
OrderNo | fine |
Order_Customer/Name | fine — association hop, then attribute |
$currentObject/Order_Customer/Name | fine — the prefix is stripped |
$currentObject/OrderNo | CE1613 |
$Order/Name | CE1613 |
Note the two-segment form: Assoc/Attr, not the XPath Assoc/Entity/Attr,
which mxbuild also rejects.
Expression KINDS are checked in the positions that declare one
Two more things reach mxbuild as CE0117 "Error(s) in expression" and are now
reported by check:
- A bare word as a member's value. Mendix expressions have no bare
identifiers, so
CHANGE $Order (Status = Closed)is E013. Write'Closed'(a literal),$Closed(a variable), orModule.Enum.Value(an enumeration). Scoped to the whole value of a create/change member: a bare name nested in a list-operation predicate is legal —FILTER($L, Status = 'Open')resolvesStatusagainst the item under test — and is not reported. - A log message's template parameter must be a String.
LOG … WITH ({1} = $Order/Qty)is E009. Measured on 11.13.0: Integer, Decimal, Boolean, DateTime and an object each fail; a String attribute is clean; andtoString(…)around any of them is clean. So wrap the non-String ones — the writer is fine, Mendix simply does not coerce here.
Three more things check now refuses
- An unqualified CREATE (
create association Order_Probe …) — MDL074, no project needed.execalways refused it; check now does too, which matters because exec is not transactional: the statements before the failure are already applied, and re-running hits "already exists" on them. RETURNS void AS $x— MDL075, no project needed. An alias names the variable a flow returns, so it cannot be paired with void; mxcli used to believe the alias and writereturn $xinto a flow with no such variable (CE0109). WriteRETURNS void, or give the alias the type it holds.empty($List)— E014.emptyis a Mendix keyword, not a function, so the parser stops at the(. Write$List = emptyorlength($List) = 0.
One thing to know about hint ordering: the reference check runs before expression checking and exits on its first error, so an unrelated mistake anywhere in a file hides every expression hint in it. If you expect an E0xx and see none, fix the reference errors first and re-run.
A variable this cannot type is left unchecked, never guessed at — a false
"no such member" would block a script that builds cleanly. Two more things are
deliberately not reported: a qualified member (Module.Assoc), which exec
already refuses when it cannot be an attribute, and any member on an entity the
script itself creates or whose attribute the script adds earlier — the
add-the-column-then-populate-it shape stays valid.
Pre-Flight Validation Checklist
Before writing any MDL, verify these requirements:
1. Check Supported Syntax
Supported in Microflows:
declare $Var type = value;(primitives only: String/Integer/Long/Decimal/Boolean/DateTime/Enumeration)$entity = create Module.Entity (...);/retrieve $entity from ... limit 1;(objects — neverdeclarean object; that fails CE0053/CE0038 and is flagged MDL043)$list = create list of Module.Entity;(lists — neverdeclarea list; that fails CE0053/CE0038 and is flagged MDL040)set $Var = expression;$Var = create Module.Entity (attr = value);change $entity (attr = value);commit $entity [without events] [refresh];(omitted = with events, Mendix's default)delete $entity;retrieve $Var from Module.Entity [where condition];$Result = call microflow Module.Name (Param = $value);(NOTset $Result = ...)$Result = call nanoflow Module.Name (Param = $value);show page Module.PageName ($Param = $value);close page;validation feedback $entity/attribute message 'message';log info|warning|error [node 'name'] 'message';if condition then ... [else ...] end if;loop $item in $list begin ... end loop;return $value;on error continue|rollback|{ handler };
Now Supported (previously not):
rollback $entity [refresh];- Reverts uncommitted changesretrieve ... limit n- Returns single entity whenlimit 1booleanwithoutdefault- Auto-defaults tofalsebuttonstyle: warningandbuttonstyle: info- Now parse correctly- Keywords as attribute names -
caption,label,title,text,content,format,range,source,check, etc. all work unquoted
NOT Supported (will cause errors):
set $var = call microflow ...- Use$var = call microflow ...(no SET)while ... end while- Useloopwith listscase ... when 'String' ...- Case values are bare enum identifiers, never quoted or qualified;case ... when Value then ... end case;itself IS supported (enum splits only), and takes noelse(MDL008) and noASaliasTRY ... CATCH- Useon errorblocksbreak/continue- Not implementedcommit message 'text'- Not in current grammar (session command only)
2. Quote All Identifiers
Best practice: Always quote all identifiers (entity names, attribute names, parameter names) with double quotes. This escapes every MDL parser keyword conflict — quotes are stripped automatically by the parser.
Caveat — quoting does not exempt platform-reserved member names. Quoting only escapes MDL parser keywords. Names the Mendix platform reserves for entity members are still rejected after the quotes are stripped:
Type(CE7247, MDL021), the system audit attributesCreatedDate/ChangedDate/Owner/ChangedBy(MDL020 — use theAutoCreatedDate/AutoChangedDate/AutoOwner/AutoChangedBypseudo-types instead), plus the CE7247 word list (ID,GUID,CurrentUser, Java keywords, …)."Type": Stringstill fails MDL021 — rename to a non-reserved name (e.g.ResourceType,TypeValue). "Always safe to quote" covers parser keywords, not these.The parse error tells you which case you are in.
'Title' is a keyword in MDL. Quote it to use it as a namemeans quoting works and nothing has to be renamed;'Type' is reserved by MENDIX itself, not just by MDLmeans it does not. Measured across the keywords mxcli hints on: 38 are rescued by quoting, 3 (Type,Default,Owner) are not.Third case — OQL keywords, where quoting works but in a different grammar.
Year,Month,Quarter,Week,Day,Hourand the other date-part words are neither MDL parser keywords nor platform-reserved: they are accepted everywhere and build at 0 errors. They bite only inside a view entity's OQL, and only unquoted (CE0174). OQL takes double-quoted identifiers just like SQL —s."Month",from Module."Year" as s— and mxcli writes them through unchanged, so the usual fix is a quote in the OQL, not a rename. mxcli reports MDL071 as a warning atCREATE/ALTERso the name is still cheap to change if you would rather rename. Applies to the entity name as well as its attributes.The exception is an alias, and that limit is OQL's own: it takes a bare identifier there for any name —
as "Total", reserved nowhere, is CE0174 too (MDL072). A view entity's attribute name is also its select alias, so a view column cannot be calledMonthat all — that one needs a rename, not a quote.Exception — never quote
$-prefixed variable/parameter references. The quote rule is for bare names (entities, attributes, associations, declared parameter names). Variable and parameter references in expressions and widget bindings stay unquoted:datasource: $X,params: { $X: MES."Order" },$currentObject. Quoting them ("$X") breaks resolution ("parameter … references '$X' but no such parameter is declared").Enumeration values: no
=. Value names may be quoted like any identifier, but the caption follows as a quoted string — there is no equals sign:create enumeration Mod.E ("Grade1" 'Grade 1', Grade2 'Grade 2');(orGrade1 caption 'Grade 1'). Writing"Grade1" = 'Grade 1'fails withmismatched input '='— the=is the problem, not the quotes.
create persistent entity Module."Customer" (
"Name": string(200),
"status": string(50),
"create": datetime
);
Both "Name" and `Name` syntax are supported. Prefer double quotes for consistency.
Run mxcli syntax keywords for the full list of 320+ reserved keywords.
3. Validate with mxcli
Always run these checks:
# Step 1: Syntax check (no project needed)
./bin/mxcli check script.mdl
# Step 2: reference validation (needs project)
# Validates microflow bodies, entity/enum references, and widget tree references
# (datasource microflow/nanoflow/entity, action page/microflow, snippet refs)
./bin/mxcli check script.mdl -p app.mpr --references
4. Common Error Patterns
| Error Message | Likely Cause | Fix |
|---|---|---|
mismatched input 'set' after call microflow | SET not valid with CALL | Use $var = call microflow ... |
mismatched input 'create' | Structural keyword as identifier | Use "create" (quoted) or rename |
no viable alternative at input | Unsupported syntax | Check supported statements list |
microflow not found | Referenced before created | Move microflow definition earlier or check spelling |
page not found | Page doesn't exist | Check qualified name with --references |
entity not found | Typo or wrong module | Use fully qualified name |
Two rules that only real validation used to catch
Both are decidable from the MDL alone and now fail check, because a project
found them the hard way — four scripts passed check with 0 errors, executed
cleanly, and mx check then reported them:
| Rule | MxBuild | What it catches |
|---|---|---|
MDL-SEC20 | CE0156 | CREATE USER ROLE with no System module role — nobody holding it can sign in or read System entities. Add System.User. Warning by default, error when the script enables security (see below). |
MDL-PAGE20 | CE5601 | A page with parameters and a Url where the URL has no segment for a parameter. Mendix binds each parameter from the URL, so the page cannot be opened by link. |
MDL-SEC20's severity follows the security level, because the underlying error
does. Measured on Mendix 11.13: the same role is CE0156 at security level
Prototype and no error at all at level Off, where roles are stored but not
validated. A blank project ships Off. So the rule warns by default and is an
error only when the script itself contains ALTER PROJECT SECURITY LEVEL set to
something other than Off — at which point the author has said which world they
are in.
MDL-PAGE20 accepts an attribute path in the segment (url: 'p006/{Customer/Name}'),
which is the usual shape — it matches the segment's leading name, not the whole
segment.
check is still necessary, not sufficient. Run mx check (or
mxcli docker check) after every exec; these two rules narrow the gap, they do
not close it.
Validation Workflow
Before Writing MDL
-
Read the skill files:
cat .claude/skills/write-microflows/SKILL.md cat .claude/skills/overview-pages/SKILL.md -
Check help for specific syntax:
./bin/mxcli syntax microflow ./bin/mxcli syntax page ./bin/mxcli syntax entity
After Writing MDL
-
Save to a file:
cat > script.mdl << 'EOF' -- Your MDL here EOF -
Run syntax check:
./bin/mxcli check script.mdl -
If errors, check specific syntax:
./bin/mxcli syntax keywords # Reserved words ./bin/mxcli syntax microflow # microflow syntax -
Run reference check (with project):
./bin/mxcli check script.mdl -p app.mpr --references -
Execute only after all checks pass:
./bin/mxcli exec script.mdl -p app.mpr
Script Execution Behavior
IMPORTANT: Script execution is atomic per statement, NOT per script.
When a script fails on statement N, statements 1 through N-1 have already been committed:
Statement 1: create module ✓ (committed)
Statement 2: create entity ✓ (committed)
Statement 3: create association ✓ (committed)
Statement 4: create view entity ✗ (failed - execution stops here)
Statement 5: create page (never executed)
Recommendations:
- Split scripts into phases when experimenting with uncertain syntax
- Use
create or replaceto make scripts idempotent - Re-run and check
git status— a settled script changes nothing - Test new syntax patterns with minimal scripts first
- Keep a backup of your project before running large scripts
Script Organization
Organize scripts in dependency order:
-- check-skip: illustrative ordering example; the PHASE 5 page block uses
-- shorthand pseudo-syntax (layout/title/parameter/widgets) for brevity, not
-- runnable MDL. See create-page for the real page syntax.
-- ============================================
-- PHASE 1: Enumerations (no dependencies)
-- ============================================
create enumeration Module.Status (
Active 'Active',
Inactive 'Inactive'
);
/
-- ============================================
-- PHASE 2: Entities (depend on enumerations)
-- ============================================
create persistent entity Module.Customer (
Name: string(200),
status: Module.Status
);
/
-- ============================================
-- PHASE 3: Associations (depend on entities)
-- ============================================
create association Module.Order_Customer
from Module.Order to Module.Customer
type reference;
/
-- ============================================
-- PHASE 4: Microflows (depend on entities)
-- ============================================
create microflow Module.ACT_Save ($Customer: Module.Customer)
returns boolean as $success
begin
declare $success boolean = false;
commit $Customer;
set $success = true;
return $success;
end;
/
-- ============================================
-- PHASE 5: Pages (depend on microflows)
-- ============================================
create page Module.Customer_Edit
layout Atlas_Default
title 'Edit Customer'
parameter $Customer: Module.Customer
widgets (
-- Can reference microflows created in Phase 4
button 'Save' call microflow Module.ACT_Save (Customer = $Customer)
);
/
Troubleshooting Parse Errors
Error: "snippet not found" / "page not found"
A reference to a document that hasn't been created yet in the script:
Error: snippet not found: MyModule.NavMenu
Error: page not found: MyModule.Customer_NewEdit
Script execution is sequential — each CREATE commits immediately. Forward references
fail because the target doesn't exist in the database at the moment the referencing
document is created.
Fix options:
- Reorder — move the target document's
CREATEearlier in the script (simplest fix) - Placeholder pattern — for circular dependencies (e.g. a snippet that shows pages
that embed the snippet), create a minimal placeholder first, then create the referencing
documents, then fill in the placeholder with
CREATE OR MODIFY— which preserves the original UUID so all existing bindings remain valid (see Resolve Forward References)
Declaration order that avoids most forward references:
enumerations → entities → snippets (placeholder) → pages → snippets (fill-in) → microflows → navigation
Never use
CREATE OR REPLACEfor the placeholder fill-in step. OR REPLACE deletes the placeholder and creates a new document with a different UUID, silently breaking every page or snippet that references it.
Error: "mismatched input 'X'"
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 122
- Forks
- 49
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
check-syntax- Source
- github.com/mendixlabs/mxcli