Custom & Pluggable Widgets in MDL
SkillDev toolsLets your agent write custom claude skill code for pluggable widgets in Mendix pages using MDL syntax.
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 Custom & Pluggable Widgets in MDL skill
About this capability
MDL syntax for pluggable widgets in CREATE PAGE / ALTER PAGE, any installed widget is named by its own name (`htmlelement frame (…) { … }`), with object lists and child slots read from its definition. Covers GALLERY, COMBOBOX, DataGrid2, charts and third-party widgets: datasource and column forms,
What this skill tells your AI
The instructions your AI receives, as published by mendixlabs/mxcli in .claude/skills/mendix/custom-widgets/SKILL.md and read by ahel’s review.
Any installed widget is named by its own name
If a widget is installed in widgets/, MDL names it directly — no keyword list,
no widget id:
htmlelement frame (tagName: 'div', tagContentMode: 'container') {
attribute a1 (attributeName: 'data-testid', attributeValueType: 'expression')
tagcontentcontainer body {
dynamictext caption (Content: 'Inside the element')
}
}
Three things there are read from the widget's definition, not from anything
hardcoded: the keyword (htmlelement, the last segment of the widget id),
the properties (the widget's own spelling — tagName, not TagName), and
the body containers — attribute is an object list (one entry per
repetition), tagcontentcontainer a child slot (holds widgets).
Ask the widget rather than guessing. describe widget <name> 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:
mxcli widget describe htmlelement -p app.mpr
Do this first when placing an unfamiliar widget. It is faster than reading this
file and it cannot go stale, because it reads the .mpk the project actually
has.
The id form is the fallback
pluggablewidget 'com.mendix.widget.web.htmlelement.HTMLElement' frame (tagName: 'div')
Use it only when two installed packages ship the same MDL name, or when you have the id and not the name. Everything below that still shows the id form works unchanged — the short form is simply the better default.
Repeated entries are BLOCKS, never a property value
A widget's repeatable property — FileUploader allowedFileFormats, HTML Element
attributes, a chart's series — is written as container blocks in the body:
htmlelement frame ( tagName: 'div' ) {
attribute a1 (attributeName: 'data-testid', attributeValueType: 'expression')
}
Not as a property value:
htmlelement frame ( attributes: [(attributeName: 'data-testid')] ) -- MDL-WIDGET27
That form is an error (mendixlabs/mxcli#999). It used to be worse than an
error: the single-key shape checked clean, exec'd successfully and the property
vanished from storage, while the multi-key shape died as missing ')' at ','.
The error now names the container keyword and rewrites your entry into the form
that works.
The same rule covers the two spellings that carry no entry to key on
(mendixlabs/mxcli#1056):
selectionhelper sh (renderStyle: 'custom', customAllSelected: []) -- MDL-WIDGET27
selectionhelper sh (renderStyle: 'custom', customAllSelected: 'something') -- MDL-WIDGET27
A widgets-typed property such as customAllSelected holds child widgets, so
it is written as a block with widgets in it rather than entries:
selectionhelper sh (renderStyle: 'custom') {
customallselected s1 { dynamictext d1 (Content: 'All') }
}
The empty form is reported from its shape, with no project needed. The scalar
form is reported only when the widget resolves, because without a definition
p: 'x' is the ordinary property form and flagging it would be a guess. Both
matter because a required slot left empty is not a silent no-op at build time —
it is CE0642 "Property '…' is required.", one per slot.
describe widget <name> -p <project.mpr> lists a widget's container keywords
under Body containers, and — for an object list — the widgets-typed slots
inside one item, with the widget types that route into each:
column object list -> columns authorable
items: showContentAs, attribute, dynamicText, …
slot content -> content: any other widget in the item body
slot filter -> filter: textfilter | numberfilter | datefilter | dropdownfilter
Read that last line before guessing where something goes. It says a Data Grid 2
column filter is written directly in the column's braces — not in
controlbar, which is the grid-wide filter bar and renders "Unable to get
filter store" if you put a column filter there.
When the name is not found
A name resolving to no installed definition is an error (MDL-WIDGET25, with
near-miss suggestions), and a container the parent does not declare is
MDL-WIDGET26. Both need -p: without a project, mxcli knows only its embedded
widgets, so it stays quiet rather than reporting every real widget as unknown.
MDL-WIDGET29 needs no project: statictext writes Forms$Text, a type Mendix
does not have, and the project that comes out cannot be loaded at all (mx check and Studio Pro both stop at TypeCacheUnknownTypeException before
validation). Use dynamictext with a literal Content:.
If a widget you have installed is not found, extract its definition:
mxcli widget init -p app.mpr
Built-in Pluggable Widgets
GALLERY
Card-layout list with optional template content and filters.
gallery galleryName (
datasource: database from Module.Entity sort by Name asc,
selection: single | multiple | none,
DesktopColumns: 3,
TabletColumns: 2,
PhoneColumns: 1
) {
template template1 {
dynamictext title (content: '{1}', contentparams: [{1} = Name], rendermode: H4)
dynamictext info (content: '{1}', contentparams: [{1} = Email])
}
filter filter1 {
textfilter searchName (attribute: Name)
numberfilter searchScore (attribute: Score)
dropdownfilter searchStatus (attribute: status)
datefilter searchDate (attribute: CreatedAt)
}
}
templateblock -> mapped tocontentproperty (child widgets rendered per row)filterblock -> mapped tofiltersPlaceholderproperty (shown above list)selection: noneomits the selection property (default if omitted)DesktopColumns,TabletColumns,PhoneColumnscontrol responsive grid columns (default: 1 each, omit if default)- Children written directly under GALLERY (no container) go to the first slot with
mdlContainer: "template"
COMBOBOX
Two modes depending on the attribute type:
-- Enumeration mode (Attribute is an enum)
combobox cbStatus (label: 'Status', attribute: status)
-- Association mode (Attribute is an association)
combobox cmbCustomer (
label: 'Customer',
attribute: Order_Customer,
datasource: database Module.Customer,
CaptionAttribute: Name
)
- Engine detects association mode when
datasourceis present (hasDataSourcecondition) CaptionAttributeis the display attribute on the target entity- In association mode, mapping order matters: DataSource must resolve before Association (sets entityContext)
Naming a datasource by its schema key
A widget may expose several datasources. Address one by its own property key
(or a registered alias) instead of the generic datasource: clause:
combobox cmbCustomer (
Association: Order_Customer,
optionsSourceAssociationDataSource: database from Module.Customer,
CaptionAttribute: Name
)
The value has to be a datasource, not an entity name. optionsSourceAssociationDataSource: Module.Customer
is MDL-WIDGET05: it names an entity, cannot be stored as a datasource, and
before mxcli rejected it, it passed check and exec and then failed the build
with CE0642 against a property nobody had mentioned (mendixlabs/mxcli#643).
A isLinked datasource is not yours to set. A widget.xml
isLinked="true" datasource is filled from the CONTAINING widget — a Data Grid 2
supplies its column filter's linkedDs ("Datasource to Filter"). A .def.json
mapping one is refused at build time. Measured on 11.6.6: five Studio
Pro-authored drop-down filters store it empty, a filter written without it passes
mx check at 0 errors, and a filter written WITH it still fails CE0642
"Property 'Datasource to Filter' is required" — mxbuild resolves the property
from the parent rather than reading what is stored, so writing it is not merely
useless. Across every widget package in testdata/expr-checker, linkedDs is
the only linked datasource among the eight multi-datasource widgets, which is why
DROPDOWNFILTER is single-source from MDL's side while COMBOBOX and the charts are
not.
The generic datasource: clause stays the convenience form for a
single-datasource widget. On one exposing several it names nothing in
particular and is refused, with the keys to use instead -- neither guess is
defensible: feeding it to every mapping duplicates one binding across unrelated
slots, and feeding it to the first leaves the others unset (CE0642 again).
describe page emits the named keys back when a widget has several configured
sources, so describe -> exec keeps each binding on its own mapping. A widget with
ONE source keeps the generic DataSource: clause it has always been described
with. A source whose schema key cannot be resolved falls back to the generic
spelling rather than being dropped.
Charts (Mendix Charts.mpk)
Charts are pluggable widgets. Install Charts.mpk into the project's widgets/
folder first (any Charts-based app has it); exec auto-generates the
.def.json.
Each is authorable by its own name — barchart, linechart, piechart,
heatmap — and the examples below use the package id form, which also still
works. The id column is kept because it is what describe widget prints and
what identifies the widget unambiguously.
Chart type → widget id → data container:
| Chart | Widget id (pluggablewidget '…') | Data block |
|---|---|---|
| Bar / Column / Area | com.mendix.widget.web.{barchart.BarChart, columnchart.ColumnChart, areachart.AreaChart} | series (one or more) |
| Line / TimeSeries / Bubble | com.mendix.widget.web.{linechart.LineChart, timeseries.TimeSeries, bubblechart.BubbleChart} | line (one or more) |
| HeatMap | com.mendix.widget.web.heatmap.HeatMap | widget-level attrs + scalecolor items |
| Pie | com.mendix.widget.web.piechart.PieChart | widget-level attrs (no object-list) |
Series / line — each binds its OWN datasource + X/Y:
pluggablewidget 'com.mendix.widget.web.barchart.BarChart' chart1 {
series s1 (
dataSet: 'static',
DataSource: database from MyModule.SalesByRegion, -- an OQL VIEW (aggregated)
staticXAttribute: Region, -- resolves against the series' own datasource
staticYAttribute: Total,
staticName: 'Revenue',
interpolation: 'linear' -- line/area only: linear | spline
)
}
A series datasource takes any of the usual kinds — database from …,
microflow …, nanoflow …, $Param, selection … — not just database.
(Before #941 describe page rendered every series datasource as database from, so a microflow-backed series described back as a missing entity.)
Pie / HeatMap bind at the WIDGET level (no series block). Both need DataSource:
ValueAttribute:; Pie also needs a requiredSeriesName:; HeatMap addsscalecoloritems:
pluggablewidget 'com.mendix.widget.web.piechart.PieChart' pie1 (
DataSource: database from MyModule.SalesByRegion,
ValueAttribute: Total,
seriesName: 'Sales by Region' -- REQUIRED (CE4899 without it)
)
pluggablewidget 'com.mendix.widget.web.heatmap.HeatMap' heat1 (
DataSource: database from MyModule.SalesByRegion,
ValueAttribute: Total -- REQUIRED (CE0642 without it)
) {
scalecolor scLow (valuePercentage: 0, colorValue: '#f7fbff')
scalecolor scHigh (valuePercentage: 100, colorValue: '#08306b')
}
Per-chart required-property gotchas (all are mxbuild errors, not check errors):
- TimeSeries —
StaticXAttributeMUST be a Date and time attribute (CE7247 otherwise). Feed it a view with a datetime column. - BubbleChart — the
lineneeds aStaticSizeAttribute:(a numeric) in addition to X/Y. - PieChart —
SeriesName:is required (CE4899);ValueAttribute:is required (CE0642). - HeatMap —
ValueAttribute:is required (CE0642).
Data feed = OQL view entities. Charts want aggregated data (one row per
category). Build a create view entity … as select … group by … and point the
chart's DataSource: at it. Never name a view column after an OQL keyword
(Quarter/Month/Year/Day → CE0174); use Period etc. (check warns —
MDL032).
CE0463 "update this widget" is EXPECTED after generating charts. mxcli writes
the WidgetType from an embedded 11.6 baseline; the installed Charts.mpk is a
different version, so Studio Pro/mxbuild flags drift. Clear it with mxcli docker check/build (they normalize the widgets and preserve your storage format). The
whole mdl-examples/doctype-tests/34-chart-widget-examples.mdl builds 0 errors
after normalization.
Do NOT run bare mx update-widgets on an MPRv2 project (an mprcontents/-folder
project — what mxcli new creates): it converts the project to single-file v1 and
deletes mprcontents/, corrupting git, breaking a running mxcli run --local
loop, and sometimes making the project unopenable in Studio Pro. mxcli docker check/build snapshot/restore the v2 files around the normalization; raw
mx update-widgets is only safe on a v1 project or a throwaway diagnostic copy.
DESCRIBE round-trips series/line/scalecolor object-lists (item names are
synthesized, e.g. series1); a Pie/HeatMap's widget-level SeriesName/datasource
are not yet reconstructed.
Adding a Third-Party Widget
Step 1 -- Extract .def.json from .mpk
mxcli widget extract --mpk widgets/MyWidget.mpk
# Output: .mxcli/widgets/mywidget.def.json
# Override MDL keyword
mxcli widget extract --mpk widgets/MyWidget.mpk --mdl-name MYWIDGET
The extract command parses the .mpk (ZIP archive containing package.xml + widget XML) and auto-infers operations from XML property types:
| XML Type | Operation | MDL Source Key |
|---|---|---|
| attribute | attribute | attribute |
| association | association | association |
| datasource | datasource | datasource |
| selection | selection | selection |
| widgets | widgets (child slot) | container name (key uppercased) |
| boolean/string/enumeration/integer/decimal | primitive | hardcoded value from defaultValue |
| textTemplate | texttemplate | TextTemplate |
| action | action | OnClick / OnChange, else the property's own key |
| expression/object/icon/image/file | skipped | too complex for auto-mapping |
Skipped types require manual configuration in the .def.json.
Action slots are matched by name, and the storage key is not the MDL name.
Mendix's own widgets suffix theirs — a BadgeButton's click slot is onClickEvent,
a HeatMap's is onClickAction, a Combobox's change slot is onChangeEvent —
so actionSourceForKey strips one Event/Action suffix before matching
onclick/onchange. That is what lets onClick: and OnChange: reach those
widgets at all.
Every other action slot is authored by the widget's own key — a named slot:
FILEUPLOADER fu (
createFileAction: microflow MyModule.ACT_CreateFile,
onUploadSuccessFile: microflow MyModule.ACT_AfterUpload
)
In the .def.json a named slot is a mapping with no source, the same shape
object-list item mappings use:
{"propertyKey": "createFileAction", "operation": "action"}
microflow/nanoflow on a named slot parse as a data source — those forms
overlap with dataSourceExprV3 and the datasource alternative has to win, or a
chart series' staticDataSource: microflow M.X would become an action. The
executor converts them, because the widget definition is the only layer that
knows the slot is action-typed. Every other action form (show_page,
save_changes, …) reaches the AST as an action directly.
A slot may be conditional, and writing into a pruned one is CE0463. DataGrid 2's
onSelectionChange is hidden when itemSelection = None, so it needs
Selection: Multiple (or Single) alongside it. mxcli check refuses the
statement with MDL-WIDGET10 rather than letting the build fail. mxcli widget describe <name> lists each slot's hidden when condition.
Object-list item action slots (chart series staticOnClickAction, popupmenu
item action) have mappings generated but the engine still skips them at apply
time. See upstream #956.
Step 2 -- Extract BSON template from Studio Pro
The .def.json only describes mapping rules. The engine also needs a template JSON with the complete Type + Object BSON structure.
# 1. in Studio Pro: drag the widget onto a test page, save the project
# 2. Extract the widget's BSON:
mxcli bson dump -p App.mpr --type page --object "Module.TestPage" --format json
# 3. Extract the type and object fields from the customwidget, save as:
Place at: project/.mxcli/widgets/mywidget.json
Template JSON format:
{
"widgetId": "com.vendor.widget.MyWidget",
"name": "My widget",
"version": "1.0.0",
"extractedFrom": "TestModule.TestPage",
"type": {
"$ID": "aa000000000000000000000000000001",
"$type": "CustomWidgets$CustomWidgetType",
"WidgetId": "com.vendor.widget.MyWidget",
"PropertyTypes": [
{
"$ID": "aa000000000000000000000000000010",
"$type": "CustomWidgets$WidgetPropertyType",
"PropertyKey": "datasource",
"ValueType": { "$ID": "...", "type": "datasource" }
}
]
},
"object": {
"$ID": "aa000000000000000000000000000100",
"$type": "CustomWidgets$WidgetObject",
"TypePointer": "aa000000000000000000000000000001",
"properties": [
2,
{
"$ID": "...",
"$type": "CustomWidgets$WidgetProperty",
"TypePointer": "aa000000000000000000000000000010",
"value": {
"$type": "CustomWidgets$WidgetValue",
"datasource": null,
"AttributeRef": null,
"PrimitiveValue": "",
"widgets": [2],
"selection": "none"
}
}
]
}
}
CRITICAL: Template must include both type (PropertyTypes schema) and object (default WidgetObject with all property values). Extract from a real Studio Pro MPR -- do NOT generate programmatically. Mismatched structure causes CE0463.
Step 3 -- Place files
project/.mxcli/widgets/mywidget.def.json <- project scope (highest priority)
project/.mxcli/widgets/mywidget.json <- template json (same directory)
~/.mxcli/widgets/mywidget.def.json <- global scope
Set "templateFile": "mywidget.json" in the .def.json. Project definitions override global ones; global overrides embedded.
Step 4 -- Use in MDL
MYWIDGET myWidget1 (datasource: database Module.Entity, attribute: Name) {
template content1 {
dynamictext label1 (content: '{1}', contentparams: [{1}=Name])
}
}
Authoring over MCP (live Studio Pro)
When mxcli runs with --mcp (writes routed to a running Studio Pro), pluggable
widgets take a different, simpler path than the MPR writer:
- No BSON template needed -- skip Step 2 entirely. Only the
.def.json(Step 1) is required. Studio Pro owns serialization overpg_patch_pageand expands every default, so the CE0463 template-mismatch class does not exist on this path. - Any registry-resolved widget is accepted -- same 3-tier resolution
(project
.mxcli/widgets/-> global -> embedded). There is no separate MCP whitelist. - Supported property operations: attribute, association, primitive,
selection, datasource, widgets (child slots), object lists, expression,
texttemplate (including
{AttrName}placeholders and<Name>Params/contentparamsbindings -> template parameters), and action (microflow Module.Flow,show_page Module.Page, or none). - Rejected loudly (widget refused, nothing sent): actions with argument mappings, other action kinds (save/cancel/close/delete/create/open-link/ nanoflow), and any operation the MCP builder does not translate. The error names each unsupported property.
- Selector-primitive pruning gotcha: Studio Pro prunes properties made
irrelevant by a mode-selector primitive's default. Example: the Image widget
drops
imageUrlunlessImageType: 'imageUrl'is also set. If a property you set does not appear in Studio Pro, check the widget's mode selector.
.def.json Reference
{
"widgetId": "com.vendor.widget.web.mywidget.MyWidget",
"mdlName": "MYWIDGET",
"templateFile": "mywidget.json",
"defaultEditable": "Always",
"propertyMappings": [
{"propertyKey": "datasource", "source": "datasource", "operation": "datasource"},
{"propertyKey": "attribute", "source": "attribute", "operation": "attribute"},
{"propertyKey": "someFlag", "value": "true", "operation": "primitive"}
],
"childSlots": [
{"propertyKey": "content", "mdlContainer": "template", "operation": "widgets"}
],
"modes": [
{
"name": "association",
"condition": "hasDataSource",
"propertyMappings": [
{"propertyKey": "optionsSource", "value": "association", "operation": "primitive"},
{"propertyKey": "assocDS", "source": "datasource", "operation": "datasource"},
{"propertyKey": "assoc", "source": "association", "operation": "association"}
]
},
{
"name": "default",
"propertyMappings": [
{"propertyKey": "attr", "source": "attribute", "operation": "attribute"}
]
}
]
}
Mode Conditions
| Condition | Checks |
|---|---|
hasDataSource | the generic datasource: clause is set, OR any of THIS mode's datasource mappings was given by name |
hasDataSource:KEY | the datasource property KEY was given (by its key or an alias) |
hasAttribute | AST widget has an attribute property |
hasProp:XYZ | AST widget has a property named XYZ |
Modes are evaluated in definition order -- first match wins. A mode with no condition is the default fallback.
Use hasDataSource:KEY when several modes are told apart by WHICH datasource is
set -- a ComboBox's association vs database mode. Bare hasDataSource cannot
distinguish them, so with two such modes the one listed first always wins.
Bare hasDataSource only consults the mode's own datasource mappings, never
every datasource-shaped property on the widget: a microflow action and a
microflow datasource parse to the same AST shape, so a widget's OnChange: would
otherwise select a datasource mode.
6 Built-in Operations
| Operation | What it does | Typical Source |
|---|---|---|
attribute | Sets Value.AttributeRef on a WidgetProperty | attribute |
association | Sets Value.AttributeRef + Value.EntityRef | association |
primitive | Sets Value.PrimitiveValue | static value or property name |
datasource | Sets Value.DataSource (serialized BSON) | datasource |
selection | Sets Value.Selection (mode string) | selection |
widgets | Replaces Value.Widgets array with child widget BSON | child slot |
texttemplate | Sets text in Value.TextTemplate (Forms$ClientTemplate) | property name (resolved as string) |
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 122
- Forks
- 49
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
custom-widgets- Source
- github.com/mendixlabs/mxcli