Warm Local Dev Loop — mxcli run --local

SkillCloud & infra

Lets your agent run claude skill mxcli run --local so app changes go live in about a second with screenshots and tracing.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Warm Local Dev Loop — mxcli run --local skill

About this capability

The warm Docker-free dev loop, `mxcli run --local`, model changes live in about a second, with watch, screenshots, metrics, tracing and external preview. Use for the fastest edit-to-running-app cycle, especially when driving the model programmatically with MDL.

What this skill tells your AI

The instructions your AI receives, as published by mendixlabs/mxcli in .claude/skills/mendix/run-local/SKILL.md and read by ahel’s review.

Overview

mxcli run --local runs a Mendix app without Docker, keeping a mxbuild --serve process and a standalone runtime hot so model changes apply in ~1 s instead of a ~30–60 s rebuild-and-restart. Use it as the fast inner loop when iterating on an app; use mxcli docker run when you need the fully-rendered browser client (see the limitation below).

When to Use This Skill

Use this when:

  • You want the fastest edit → running-app loop for a Mendix 11.x project.
  • You're driving the model programmatically (mxcli exec/MDL) and want each change live immediately.
  • You're iterating on page design (the app renders in a real browser) or doing runtime/model/API/headless verification.

Prefer mxcli docker run when:

  • The project is Mendix 9/10 (JDK 11/17 — not yet supported by run --local).
  • You want a container-parity deployment rather than a standalone runtime.

Usage

# boot once and keep serving (Ctrl-C to stop)
mxcli run --local -p app.mpr

# boot and hot-apply on every project change
mxcli run --local -p app.mpr --watch

How apply is chosen

Every warm rebuild reports whether a restart is required; run --local applies the cheapest action automatically:

ChangeApplyCost
page / microflow / nanoflow / texthot reload_model (no restart)~1 s
entity / view entity / associationruntime restart + DDL~9 s

Structural changes need a restart because the runtime reconciles its entity/ association catalog only at startup; behavioural changes are hot-reloaded.

Prerequisites

  • Mendix 11.x project (runtime launches under JDK 21).
  • A PostgreSQL database (defaults: 127.0.0.1:5432, user mendix, db derived from the project name; override with --db-host/--db-name/--db-user/--db-password).
    • --ensure-db provisions it for a fresh session: starts local Postgres if the port is down and creates the 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) — the latter needing no postgres OS account or sudo. Remote hosts are only checked, not provisioned. In a non-root devcontainer the service start is elevated with sudo -n (Debian's init script aborts on a permission denial before it reaches any cluster), and the superuser is reached through root where the sudoers policy permits only that target — the devcontainer default. Both are non-interactive, so a run never blocks on a password prompt; where sudo is unavailable the user-owned cluster still carries it. 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).

    • Without --ensure-db, create it once and the command errors if it's unreachable:

      createdb -h 127.0.0.1 -U mendix "$(basename app.mpr .mpr | tr '[:upper:]' '[:lower:]')"
      

Which mxbuild the loop uses

  • Linux — the CDN download cached at ~/.mxcli/mxbuild/<version>/, as before.
  • macOS / Windows — Studio Pro's bundled mxbuild, resolved before the cache. The Mendix CDN publishes Linux archives only (the URL varies by architecture, not by OS), so a cached download on a Mac is a Linux aarch64 ELF — the arch matches, which is why it looks fine until exec.
  • --mxbuild-path overrides both. It is honoured by the local loop (#916) and accepted by run --local (#1125) — between those two fixes the skill said the first and the command rejected the flag, so the advertised workaround did not exist on the platform that needed it.

If nothing runnable is found, the command says so up front instead of failing with fork/exec …: exec format error:

mxbuild from the Mendix CDN is a Linux binary and cannot run natively on darwin
  Install Mendix Studio Pro 11.12.0 and use its bundled mx …
  Or point mxcli at it explicitly with --mxbuild-path.

Windows and macOS toolchain

  • JDK 21 — Mendix Studio Pro does not bundle one; its installer puts Eclipse Temurin JDK 21 in the usual place, which is where mxcli looks (Eclipse Adoptium / Java / Microsoft under both Program Files, plus the per-user %LOCALAPPDATA%\Programs\… installs winget produces). JAVA_HOME wins over all of them. When nothing is found the error now lists every location it searched.
  • Gradle — bundled inside mxbuild (modeler/tools/gradle) and invoked by mxbuild, not by mxcli. Studio Pro extracts its own copy to the parent of its install directory (usually C:\Program Files\Mendix). A "Gradle not found" from a local run therefore points at an incomplete or foreign mxbuild bundle — check which mxbuild was resolved before looking for a system Gradle.

The intended loop

# terminal 1: keep the app hot
mxcli run --local -p app.mpr --watch

# terminal 2 (or an agent): edit the model — the change hot-applies automatically
mxcli exec add-page.mdl -p app.mpr

--watch observes two source trees and rebuilds when either changes: the model source (.mpr + mprcontents/, v1 and v2 layouts) and the theme source (theme/ and themesource/<module>/web/ — SCSS/CSS/JS). It ignores build output (deployment/, theme-cache/, .mendix-cache/, .mxcli/). Both signals are mtime polling, so they work on container filesystems where inotify is silent. Each apply is logged with a build-generation counter (build #2, …) so you can confirm a change landed.

Editing themes (SCSS) — rebuild, never clear caches

A theme edit (theme/web/main.scss, module SCSS) needs a rebuild, not a cache-clear. With --watch, just save the .scss — the theme source is watched and the loop rebuilds and hot-applies. Without --watch, nothing is watched, so the save does nothing until you restart run --local (or re-run with --watch). mxbuild --serve recompiles the theme on its next build and correctly picks up SCSS content changes, so never rm -rf theme-cache/ .mendix-cache/ deployment/ — clearing caches is a red herring.

A theme compile error fails the build step, before the runtime starts — the error text is in the serve /build output (printed by run --local), not a runtime problem. A common Dart Sass trap: rgba() needs a real color, so a comma-list variable is rejected —

// WRONG: Dart Sass reports "$color: 168, 50, 30 is not a color"
$cf-over: 168, 50, 30;
background-color: rgba($cf-over, 0.1);

// RIGHT: make the variable a color, then adjust alpha
$cf-over: rgb(168, 50, 30);
background-color: rgba($cf-over, 0.1);

--watch waits for a write to finish before it builds

The watcher polls the model source and rebuilds when it changes — but it does not rebuild the instant it sees the first change. It waits for the source to stop moving first.

That matters because an mxcli exec of a real script rewrites the .mpr and many mprcontents/*.mxunit files over several seconds. Building on the first change deploys whatever is on disk at that instant: a half-applied model, which looks like an ordinary stale build until you notice the app is missing things the script definitely created.

The escape hatch that used to cover this — "just run the script again" — stopped working when exec became byte-idempotent. A re-run of an already-applied script writes nothing, so nothing re-triggers the watcher, and the stale build is what you are left with. Waiting for the write to settle is what makes that unreachable rather than merely unlikely.

Practical consequences:

  • A rebuild starts a couple of poll intervals after your last change, not immediately. A single editor save is unaffected in practice.
  • A long exec produces one build, of the finished model, instead of a build of the first file it happened to touch.
  • If you ever do need to force a rebuild without changing anything, touch the .mpr — the watcher keys on mtime, so that re-triggers it.

"My edit didn't show up" — stale process, not stale cache

run --local refuses to boot when its ports (8080/8090/6543) are already answering, because a leftover run --local / mxbuild --serve / runtime would otherwise be silently adopted and keep serving old output (it looks like a cache but is a stale process). If a background run --local died while its serve+runtime kept serving, the refusal names the pid — on Linux it resolves the listener through /proc, so recovery is one command:

port 8080 (app) is already in use.
  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

It also distinguishes the two cases, which need opposite remedies: a leftover of a previous run is safe to kill, while a foreign listener (someone else's server on 8080) is not — for that it says so and points at --app-port.

Note that a graceful stop already reaps everything: run --local puts each child (mxbuild's JVM, the runtime, the rollup bundler) in its own process group and kills the group on Ctrl-C/SIGTERM. Reaching this error means the previous run was killed with kill -9, crashed, or had its container reaped — none of which run any handler. Do not pkill -f 'mxcli run': that pattern also matches the shell you type it in.

Launch run --local as the sole command in its invocation (don't chain a trailing sleep/curl whose non-zero exit can kill the backgrounded run); poll separately.

Flags

FlagDefaultPurpose
--local—Required; run without Docker (implied by --hub)
--hub—Expose the app in a browser at a tunnel-hub URL (see below)
--hub-secret—Shared auth (user:pass) matching an open hub's --secret
(hub API key)—For an authenticated hub: get one from https://<hub>/cli, set MXCLI_HUB_KEY (see below)
--watchoffRebuild + hot-apply on each change
--ensure-dboffProvision local Postgres + app database if missing
--setupoffCache MxBuild+runtime + ensure DB, then exit (SessionStart bring-up)
--screenshotoffPlaywright PNG after boot + each change
--screenshot-path / --screenshot-url.mxcli/run-local.png / app rootScreenshot output / page (URL or /path)
--screenshot-user / --screenshot-password—Log in once, reuse session (pages behind login)
--runtime-log.mxcli/runtime.logRuntime log file: JVM stdout/stderr and the application log (microflow LOG output + server stack traces, via an attached file log subscriber). - disables.
--test-endpointoffHost mxcli's token-guarded test endpoint so mxcli test … --attach can run a suite against this app with no boot of its own. Installed before the boot (the handler registers from after-startup), your own after-startup microflow is chained not displaced, and both are removed on exit. See test-microflows.
--debugoffEnable the microflow debugger at boot + start a session, so mxcli debug break/paused/… works from another terminal (see debug-microflows). No breakpoints = no behaviour change; disabled on shutdown.
--debug-passmxdebugDebugger password when --debug is set
--metricsoffRegister a Prometheus meter registry at boot; the runtime serves metrics at http://127.0.0.1:<admin-port>/prometheus
--traceoffEnable OpenTelemetry tracing (bundled agent, console exporter → the 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.

Metrics and OpenTelemetry

Metrics (--metrics): the Mendix runtime ships Micrometer registries but starts with none. --metrics registers a Prometheus registry at boot, so http://127.0.0.1:8090/prometheus (the admin port) serves ~70+ metric families (connectionbus_*, handler_requests_total, sessions_*, taskqueue_*, …). For a different registry use --runtime-setting, e.g. --runtime-setting 'Metrics.Registries=[{"type":"otlp","settings":{"step":"PT10S"}}]' (also influx, statsd, jmx).

Why this is a flag, not a post-boot API call: the admin update_configuration action replaces the whole config (there's no read-back), so a separate call to add metrics would wipe the DB/BasePath settings. --metrics/--runtime-setting merge into mxcli's single boot update_configuration, which is the only safe way.

Traces (--trace): the runtime bundles the OpenTelemetry Java agent; --trace attaches it to the runtime JVM and applies the default span filters:

mxcli run --local -p app.mpr --trace                 # spans -> runtime.log
mxcli run --local -p app.mpr --trace --runtime-log - # spans to console only

Spans (tracer com.mendix.runtime, attrs mx.microflow.name/mx.microflow.depth) go to the console exporter → runtime.log (so tail -f .mxcli/runtime.log shows them). --trace sets OTEL_SERVICE_NAME (default the .mpr name, override with --trace-service) and, unless you set them yourself, OTEL_TRACES_EXPORTER=console with metrics/logs exporters off.

Why the default span filters matter: unfiltered per-activity tracing is ~10× slower, so --trace ships OpenTelemetry._RuntimeSpanFilters = ["CreateOrChangeVariable","Loop","Gateway","RetrieveFromCache"] by default (keeping the microflow-level spans). Override with --runtime-setting 'OpenTelemetry._RuntimeSpanFilters=[…]'.

Export to a collector (OTLP) instead of the console: 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. Pass --trace-otlp <endpoint> (implies --trace); mxcli sets OTEL_TRACES_EXPORTER=otlp, OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf, and OTEL_EXPORTER_OTLP_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 never override an exporter you've already set:

export OTEL_TRACES_EXPORTER=otlp OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
mxcli run --local -p app.mpr --trace

| --app-port / --admin-port / --serve-port | 8080 / 8090 / 6543 | Ports | | --db-host / --db-name / --db-user / --db-password | 127.0.0.1:5432 / derived / mendix / mendix | Database; bracket IPv6 endpoints ([::1]:5432) |

Pages render in the browser

run --local bundles the browser client (web/dist/) with mxbuild's rollup tooling after the deploy build, so the app renders in a real browser (verified with Playwright + the devcontainer's Chromium).

  • --watch keeps a long-lived incremental bundler hot (the client-side mirror of mxbuild --serve): a page/widget edit re-bundles in ~3–4 s; a microflow/entity edit skips the bundle and just hot-reloads. It uses CHOKIDAR_USEPOLLING because inotify is silent on container filesystems.
  • Without --watch, a single one-shot bundle (~7 s) runs before boot.
  • The bundle is re-checked after the boot, because bundling before it is not enough: the runtime's boot runs Gradle clean-custom-classes compile package, and when Gradle has work to do (a new Java action, a full recompile) its package pass repopulates deployment/web and deletes dist/ — the bundle written seconds earlier by the same command. If that happened, run --local says re-bundling and rebuilds it. When Gradle had nothing to do the check is a stat and costs nothing.

If you ever see a black page: that is this failure, and nothing else reports it — mxcli check passes, the build succeeds, the runtime log is quiet, curl / returns 200 with a valid HTML shell, and the OData services all answer. Only a browser sees it. Confirm with curl -o /dev/null -w '%{http_code}' <app>/dist/index.js; a 404 there is the whole diagnosis.

mxcli test --local boots the same way and destroys the bundle too. Tests are headless so it is not rebuilt for them (that would cost ~30 s on a loop whose point is two seconds) — the run prints a note instead, and a subsequent run --local restores it.

Screenshots when the app has an https root URL (--hub)

Under --hub the runtime boots with the public https root URL, so it marks its session cookies Secure and prefixes them __Host-. The screenshot login therefore declares the real scheme with X-Forwarded-Proto: http, which on Mendix 10.24+ takes precedence over ApplicationRootUrl and drops both — so the captured session is usable over http.

Measured on 11.12.1, __Host-XASSESSIONID (secure) becomes XASSESSIONID (not secure). Real users still arrive over https through the hub without that header and still get Secure cookies.

One correction to a common assumption: this is not needed for 127.0.0.1. Loopback is a trustworthy origin, so Chromium accepts Secure cookies there and an app with an https root URL renders and logs in fine over http://127.0.0.1:8080. It matters when the browser reaches the app from a non-loopback host — a container name, a LAN address — where the origin is not trustworthy and the session cannot be held at all.

Pixel-perfect page loop

--screenshot captures a PNG (default <projectDir>/.mxcli/run-local.png) after boot and after each applied change, via Playwright's built-in screenshot command (Chromium from PLAYWRIGHT_BROWSERS_PATH):

mxcli run --local -p app.mpr --watch --screenshot
# edit a page -> auto rebuild -> re-bundle -> reload -> fresh screenshot
  • --screenshot-url /p/customers shoots a specific page (bare path resolved against the app root; a full URL is used as-is). Repeat it for a multi-page set — each page gets its own PNG (run-local-p-customers.png, run-local-home.png).
  • --screenshot-user/--screenshot-password log in once (Mendix form auth) and reuse the session, so pages behind login render authenticated. Best-effort: an anonymous app with no login form proceeds unauthenticated.

Debugging a server-side error

When a page action throws, the browser shows the generic Mendix error dialog with no detail. The runtime log — server stack traces and your microflow LOG ERROR/ LOG INFO output — 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 stack and correlate it.

Two things feed that file, because a standalone runtime attaches no log subscriber by default (a Studio Pro / m2ee run does):

  • mxcli tees the runtime JVM's stdout/stderr to it (startup output, JVM-level crashes).
  • After start, mxcli attaches a Mendix file log subscriber so the application log — microflow LOG output and server-side exception stack traces — lands there too. Without this, application logs go nowhere and the file is nearly empty.

The file is appended across restarts (each boot writes a === runtime start … === marker); the subscriber is re-attached on every restart and never rotates the file (so the JVM tee's handle stays valid). Override the path with --runtime-log <path>, or pass --runtime-log - to disable the file (and the subscriber) entirely.

"Sign in failed" that is not about the password

The local runtime is unlicensed, and an unlicensed runtime caps concurrent sessions at a handful. Past the cap it refuses the sign-in, and the login page reports that as a plain "Sign in failed" — exactly what a wrong password looks like. The real reason is written only to the runtime log:

Maximum number of sessions exceeded! (You are currently using a trial license)

So: when a login you know is correct starts failing, grep -c "Maximum number of sessions" .mxcli/runtime.log before touching the credentials or the user's password in the model. --screenshot-user does this for you — a rejected sign-in now reads the log and says so instead of quietly screenshotting the login page.

Sessions are held until they expire; restarting run --local clears them all. A script that drives the app through a browser should sign out at the end, otherwise each run leaks a slot and the fifth or sixth run is the one that fails — which makes it look like a change you just made broke authentication.

The same licence also stops the app after a few hours

The development licence has a maximum run time, and the runtime enforces it by shutting itself down. It warns at every boot, which reads as boilerplate until the day it matters:

WARNING - LicenseService: The runtime has been started using a trial licence,
          the framework will be terminated when the maximum time is exceeded!
...
LicenseService: Maximum run time exceeded, framework is now terminating

Measured on one machine: 3h52m and 5h07m — not a fixed number, and shorter than a working session. Budget for a restart on anything long-running.

mxcli run now exits when the runtime stops, with a non-zero status and the reason, so a shell loop or systemd can restart it:

while ! mxcli run --local -p app.mpr; do echo "restarting"; done

Two traps this closes, both of which cost hours before it did:

  • A 200 from a tunnelled URL is not evidence the app is alive. Under --hub the tunnel outlives the runtime, so the preview URL keeps answering over a dead app. Health-check the app itself, and check something the app serves — /dist/index.js, not /, which returns the SPA shell either way.
  • mxcli run at several hundred percent CPU is not working hard. It used to keep supervising a runtime it had neither reaped nor noticed the death of; a Z (zombie) process underneath it is the tell.

External browser preview (--hub)

Linux builds only. --hub and mxcli tunnel-hub ship in the Linux build only. The tunnel embeds a general-purpose tunnelling tool that gets the Windows and macOS binaries flagged by Defender and enterprise EDR for a capability they can never use, so it is left out of them. On Windows/macOS the commands exist and show help, but fail with an explanatory message — run mxcli inside the project's devcontainer (where the warm loop already runs) to use --hub. See ADR-0009.

--hub <url> exposes the running app in a browser at a public URL without the app leaving this machine and without committing — for reviewing work-in-progress from a phone/tablet, or from an egress-only environment like Claude Code on the web. The app stays here; a reverse tunnel dials out to a hub over 443 and the hub proxies browser requests back down it. Nothing is pushed — only live HTTP — and everything rides one 443 connection, so it works through 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

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
122
Forks
49
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
run-local
Source
github.com/mendixlabs/mxcli