Chart Control Protocol v1
How mt5-httpapi’s Chart Deployments feature attaches Expert Advisors to charts remotely, keeps them running, and reports what’s actually live, without RDP, without restarting terminals, and without any orchestrator.
MT5 has no SDK call to attach an EA to a chart. The only programmatic path is ChartApplyTemplate() from MQL5 code already running inside the terminal. So the API and a small resident loader EA cooperate over a handful of JSON files in the terminal’s MQL5\Files\chartctl\ sandbox.
The protocol is intentionally file-based so that:
- it needs zero WebRequest whitelist entries (works on locked-down terminals),
- it’s trivially debuggable over RDP during rollout, and
- any EA can implement it: the bundled
MT5ChartLoader, or your own resident utility EA (e.g. an account tracker) that adoptsChartControl.mqh.
Roles
| Side | Writes | Reads |
|---|---|---|
| API (mt5-httpapi) | desired.json, command.json, generated .tpl files | observed.json, command_result.json |
| Loader EA (in terminal) | observed.json, command_result.json, owned.json, screenshots | desired.json, command.json, owned.json |
The API owns desired state. The loader owns observed truth. Neither writes the other’s files. Success is defined as observed converging on desired, never as “the API copied some files.”
Files (all under MQL5\Files\chartctl\)
desired.json (API → loader)
{
"protocol": 1,
"revision": 42,
"updated_at": "2026-07-09T12:00:00Z",
"reconcile_interval": 5,
"deployments": [
{
"id": "dep_a1b2c3",
"expert": "HappyGoldScalp",
"template": "\\Files\\chartctl\\dep_a1b2c3.tpl",
"symbol": "XAUUSD",
"timeframe": "M5",
"enabled": true
}
]
}
revision is a monotonic counter that the loader echoes back as applied_revision. reconcile_interval is the API’s chartctl.reconcile_hint_interval in seconds, a hint for custom loaders; the bundled loader ignores it and reconciles every second. template is passed verbatim to ChartApplyTemplate(); the leading backslash makes MT5 resolve it against <data>\MQL5, the only search root that doesn’t depend on which EX5 hosts the loader (paths without a leading backslash resolve relative to the calling EX5’s own folder, and the templates\ GUI directory is never searched). The API generates one .tpl per deployment, written into the MQL5\Files\chartctl\ protocol directory.
observed.json (loader → API, rewritten every pass, ~1s)
{
"protocol": 1,
"loader": { "name": "MT5ChartLoader", "version": "1.0.5",
"last_loop": "2026-07-09 12:00:03", "applied_revision": 42 },
"terminal": { "auto_trading": true },
"charts": [
{ "chart_id": 133039117, "symbol": "XAUUSD", "timeframe": "PERIOD_M5",
"expert": "HappyGoldScalp", "expert_enabled": true,
"deployment_id": "dep_a1b2c3" }
],
"deployments": [
{ "id": "dep_a1b2c3", "status": "running", "chart_id": 133039117 }
],
"errors": []
}
version is CHARTCTL_VERSION in ChartControl.mqh. The loader reports each desired deployment as running (with its chart_id), pending or paused. Each failing deployment gets one errors[] entry, cleared when it next attaches:
{ "id": "dep_a1b2c3", "status": "failed", "code": "EXPERT_NOT_ATTACHED",
"detail": "template applied but CHART_EXPERT_NAME empty after 10s ..." }
The codes are SYMBOL_NOT_FOUND, CHART_OPEN_FAILED, TEMPLATE_APPLY_FAILED and EXPERT_NOT_ATTACHED.
applied_revision == desired.revision and deployment status: running is the only definition of a converged deployment. The API’s GET /deployments merges the two files and derives per-deployment status (pending, running, degraded, failed, paused; see Chart Deployments).
owned.json (loader’s own state)
{ "owned": [ { "chart_id": 133039117, "deployment_id": "dep_a1b2c3" } ] }
Which open chart belongs to which deployment. The loader keeps this map in memory, writes it whenever it changes, and reads it back when it starts, so a reloaded loader still knows its charts. The API never reads it; observed.json already carries each chart’s deployment_id.
command.json / command_result.json (one-shot commands)
For operations that produce an artifact rather than converge state (currently screenshot and close_chart; the loader also understands a reconcile nudge, which the API does not send, because POST /deployments/reconcile bumps the revision instead). One command in flight, so a second API call meanwhile answers 409 LOADER_BUSY; the loader writes the result keyed by command_id and deletes the command.
{ "command_id": "cmd_1a2b3c4d", "action": "screenshot",
"chart_id": 133039117, "width": 1280, "height": 720 }
{ "command_id": "cmd_1a2b3c4d", "status": "ok", "file": "cmd_1a2b3c4d.png" }
A screenshot lands in MQL5\Files\chartctl\shots\<command_id>.png. A failed command answers "status": "error" with error_code (SCREENSHOT_FAILED, CLOSE_REFUSED, CLOSE_FAILED, UNKNOWN_ACTION) and error_detail.
close_chart closes an arbitrary chart by chart_id (from the observed charts list), the cleanup escape hatch for charts the loader cannot attribute. The loader refuses (CLOSE_REFUSED) to close its own chart.
Loader responsibilities
Every pass (timer-driven, ~1s), the owning loader:
- Answers any pending
command.json. - Reads
desired.jsonand reconciles, on every pass whether or notrevisionchanged. Reconcile = diff desired deployments against actual charts:- Enabled deployment with no chart in the ownership map → first try to adopt an unowned chart already running the exact expert + symbol + timeframe and record it in
owned.json; only if none exists,SymbolSelect→ChartOpen→ChartApplyTemplate→ verifyCHART_EXPERT_NAMEwithin 10s → record the chart inowned.json. A failed attach closes the chart it opened and backs off 60s. - Enabled deployment whose chart has no expert (removed, or its
OnInitfailed) → re-arm:ChartApplyTemplateon that same chart and verify, instead of opening another. A failed re-arm keeps the chart and backs off 60s. - A chart it owns whose deployment is gone or disabled →
ChartClose, and the chart leaves the map. - Map entries for charts that no longer exist are dropped.
- Charts it doesn’t own are reported but never touched by reconcile (an explicit
close_chartcommand can close them).
- Enabled deployment with no chart in the ownership map → first try to adopt an unowned chart already running the exact expert + symbol + timeframe and record it in
- Writes
observed.json.
Rules
- The loader never calls trade functions. Chart lifecycle only.
- Exactly one loader per terminal, guarded by a terminal
GlobalVariablemutex (chartctl_loader_owner). The owner touches it every pass. A second loader that finds it touched within 120 seconds stays passive, reclaiming only if the owner vanishes. - All file writes are atomic (temp +
FileMove). - Attribution is by the
owned.jsonmap, never by anything on the chart itself. Loader 1.0.2 and earlier wrotechartctl:<id>into the chart comment, which any expert callingComment()overwrites. MT5 gives the charts it restores from the saved profile new ids, so after a terminal restart the map’s old entries are dropped and reconcile adopts the exact-match restored charts instead. Experts on restored charts load a few seconds after the terminal starts, so for its first 30 seconds the loader only adopts and opens no new charts; opening one earlier would duplicate a chart that is still loading. - MT5 writes a chart’s expert into the saved profile only when it exits cleanly. After a hard stop (a killed terminal,
POST /terminal/restart, the health monitor’s recovery, a VM restart) it restores a deployment’s chart as the profile had it when the loader opened it, without the expert. So when the 30 seconds end, a deployment that was inowned.jsonat startup and still has no chart re-arms an unowned expert-less chart on its symbol and timeframe, once. After that pass the loader never touches an unowned expert-less chart. - A chart leaves the map only once
ChartClosesucceeds. - MT5 only loads an
.ex5that was on disk when the terminal started, or that a Navigator refresh has picked up since.POST /expertsruns that refresh inside the Windows VM; see Chart Deployments.
Self-healing
Two layers converge a terminal back to desired state after any restart (including the optional reboot_interval VM reboots):
- MT5 native chart restoration brings back charts + attached experts from the last saved profile. Often that covers everything, with no work from the loader.
- Loader reconciliation repairs whatever native restoration missed (a hard stop that restored a chart without its expert, chart closed by hand, failed
OnInit).
Neither layer helps when the loader itself is not running. Nothing alerts on that; GET /loader answers alive: false once observed.json is older than chartctl.observed_stale_after.
Implementing the protocol in your own EA
The whole loader is a portable include. In your resident EA:
#include <ChartControl.mqh>
CChartControl ctl;
int OnInit() { if(!ctl.Init()) return INIT_FAILED;
EventSetTimer(1); return INIT_SUCCEEDED; }
void OnTimer(){ ctl.Tick(); }
void OnDeinit(const int r){ ctl.Deinit(); }
That’s it. Your account tracker (or any always-on EA) becomes the loader, so one resident EA does telemetry and chart deployment instead of two. The mutex makes running both your EA and the standalone MT5ChartLoader degrade safely. See assets/experts/MT5ChartLoader.mq5 for the reference glue and assets/experts/include/ChartControl.mqh for the implementation.
Bootstrapping the loader
Zero-touch (default). Provisioning does everything. No RDP, no manual attach, fully API/config-driven:
- On VM boot,
start.batrunscompile-chartctl-loader.bat, which copiesChartControl.mqh+MT5ChartLoader.mq5into every broker base terminal, compiles with that base’s MetaEditor64, and propagates the.ex5into every existing terminal instance (new instances inherit it from the base copy). config_helper.pywrites a[StartUp] Expert=Advisors\MT5ChartLoadersection into each terminal’s generatedmt5start.ini, withSymbol=EURUSDplus the terminal’ssymbol_suffix(e.g.EURUSD.r) andPeriod=H1, so the terminal attaches the loader itself at every launch.- The
[StartUp]line re-fires on every launch (including the periodicreboot_intervalreboots); the GlobalVariable mutex makes this idempotent. A duplicate loader closes its own chart and vanishes, so charts never accumulate.
Both steps honor the same gating as the API: live-mode terminals only, chartctl.enabled globally, per-terminal chartctl: false opts out.
Manual (fallback / existing fleets). Drag MT5ChartLoader onto any chart once over RDP, or fold ChartControl.mqh into a resident EA you already deploy.
Endpoint summary
| Method | Path | Purpose |
|---|---|---|
POST | /experts | Upload .ex5 (multipart) |
GET | /experts | List staged experts |
DELETE | /experts/<name> | Remove staged expert (refused if in use) |
POST | /sets | Upload .set (returns parsed inputs) |
GET | /sets, /sets/<name> | List / inspect set files |
DELETE | /sets/<name> | Remove staged set (refused if in use) |
POST | /deployments | Declare a deployment (EA+set+symbol+TF) |
GET | /deployments, /deployments/<id> | Desired ⋈ observed status |
PATCH | /deployments/<id> | Change set file or pause/resume |
DELETE | /deployments/<id> | Remove a deployment |
POST | /deployments/reconcile | Force the loader to re-reconcile |
GET | /charts | Live chart/EA inventory from the terminal |
GET | /loader | Loader presence/version/liveness |
POST | /charts/<chart_id>/screenshot | PNG of a chart |
POST | /charts/<chart_id>/close | Close a chart (incl. unattributed ones) |
All routes sit behind the terminal’s existing per-account route prefix and bearer-token auth. Nothing here touches the MT5 SDK lock.