Chart Deployments: remote EA deployment over HTTP
Attach Expert Advisors to charts with set files over the HTTP API. No RDP, no terminal restart, and no human clicking through the Navigator.
Contents
- Quick start
POST /expertsand the Navigator refreshPOST /deploymentsGET /deploymentsand the other deployment routesGET /loaderGET /charts- Screenshots and closing charts
- WebRequest allowlist
- Chart Control Protocol, the file contract the loader speaks
Video walkthrough: Coming soon. A full walkthrough of staging, deploying, and debugging EAs via the chartctl API.
Attach Expert Advisors to charts with set files over the HTTP API. No RDP, no terminal restart. Stage an .ex5 + .set, declare a deployment (expert + set + symbol + timeframe), and a resident loader EA inside the terminal reconciles the terminal’s actual charts to match. The API holds desired state; the loader reports observed truth back, so a deployment only reads running once the expert is confirmed live on a chart.
Any client (a dashboard, a script, an AI agent) drives it over plain REST. Full protocol contract and file formats: docs/chart-control-protocol.md.
AI agents get the same thing as typed MCP tools, file uploads and chart screenshots included. See MCP and agent integrations.
| Method | Endpoint | Description |
|---|---|---|
POST / GET / DELETE | /experts /experts/<name> | Stage, list, remove EA .ex5 files |
POST / GET / DELETE | /sets /sets/<name> | Stage, list, inspect (parsed), remove .set parameter files |
POST / GET | /deployments | Create or list deployments |
GET / PATCH / DELETE | /deployments/<id> | Inspect, pause/resume/change set, tear down a deployment |
POST | /deployments/reconcile | Force an immediate reconcile cycle (otherwise periodic) |
GET | /charts | Live chart/EA inventory from inside the terminal |
GET | /loader | Loader EA status (alive, version, chart open count) |
POST | /charts/<chart_id>/screenshot | Capture a chart PNG from inside the terminal |
POST | /charts/<chart_id>/close | Close a chart by id (any chart, including leaks) |
DELETE /experts/<name> and DELETE /sets/<name> answer 409 IN_USE while any deployment, paused ones included, names the file, because the API rebuilds a deployment’s chart template from its expert and set on every change. Delete the deployment first. Host-managed files under assets/ that were never copied into the terminal answer 403 HOST_ASSET. Staged sets live in the terminal’s chartctl/sets/, which the file API only reads, so remove them here.
Opt-in. chartctl.enabled defaults to false; set it to true in config.yaml to switch the feature on. Live-mode terminals only, and any single terminal can stay clear of it with chartctl: false.
That default is deliberate. Enabling this writes a [StartUp] section into every live terminal’s INI, so the loader EA attaches itself at launch. That is fleet-wide behaviour that must be asked for, never inherited from an upgrade. With the block absent nothing changes and the endpoints return 404.
Once enabled, setup is none. On boot, provisioning auto-compiles the bundled loader EA (assets/experts/MT5ChartLoader.mq5) in every broker base and wires a [StartUp] Expert= line into each live terminal’s mt5start.ini, so the loader attaches itself at launch. The whole path is API/config-driven, no RDP. Already have a resident utility EA on every terminal? Adopt the protocol into it with three calls instead of running a second EA. See assets/experts/include/ChartControl.mqh. The standalone loader steps aside automatically (single-loader mutex via terminal GlobalVariable).
Settings. The rest of the chartctl block in config.yaml tunes the feature. Durations take the same grammar as utc_offset, so a bare number is hours (command_timeout: 30 is 30 hours); write units such as "30s". A duration that cannot be parsed stops the API from starting. A value below the floor is raised to it.
reconcile_hint_interval(default"5s", floor 1 second): written intodesired.jsonasreconcile_interval, a hint for custom loaders. The bundled loader ignores it and reconciles every second.observed_stale_after(default"60s", floor 1 second): when the loader’sobserved.jsonis older than this, the loader counts as not alive inGET /loaderandGET /charts, a running deployment readsdegraded, andconvergedis false.command_timeout(default"30s", floor 1 second): how long the API waits for the loader to answer a one-shot command such as a screenshot.max_upload_bytes(default16777216, 16 MiB, floor 1 KiB): the largest.ex5or.setthatPOST /expertsandPOST /setstake.navigator_labels: the Navigator’s labels on a terminal that does not run MT5 in English; seePOST /expertsand the Navigator refresh.
Quick start
# 0. Set your base URL + auth token
export MT5_API_URL=http://localhost:8888/yourbroker/yourlogin
export MT5_API_TOKEN=$(grep api_token config/config.yaml | awk -F'"' '{print $2}')
# 1. Stage an expert (.ex5) and a set file (.set)
curl -H "Authorization: Bearer $MT5_API_TOKEN" \
-F "expert=@HappyGoldScalp.ex5" "$MT5_API_URL/experts"
curl -H "Authorization: Bearer $MT5_API_TOKEN" \
-F "set=@gold-m5.set" "$MT5_API_URL/sets" # returns parsed inputs
# 2. Deploy: declare desired state
curl -X POST "$MT5_API_URL/deployments" \
-H "Authorization: Bearer $MT5_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"expert":"HappyGoldScalp.ex5","set":"gold-m5.set","symbol":"XAUUSD","timeframe":"M5"}'
# -> 202 {"id":"dep_a1b2c3d4","status":"pending","deployment":{...}}
# 3. Verify: status flips to "running" once the loader confirms attach
curl -H "Authorization: Bearer $MT5_API_TOKEN" "$MT5_API_URL/deployments"
# 4. Change the set file, pause, or tear down. A new set file reaches the
# expert the next time the loader opens its chart, not while it runs. To
# apply it now: pause, wait until GET /charts no longer lists the
# deployment's chart (its status says "paused" right away, before the
# loader acts), then resume. Resuming is refused with 409 DUPLICATE_CHART
# while another enabled deployment runs on the same symbol and timeframe.
curl -X PATCH "$MT5_API_URL/deployments/dep_a1b2c3d4" \
-H "Authorization: Bearer $MT5_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"set":"gold-m5-v2.set"}'
curl -X PATCH "$MT5_API_URL/deployments/dep_a1b2c3d4" \
-H "Authorization: Bearer $MT5_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"enabled":false}'
curl -X DELETE "$MT5_API_URL/deployments/dep_a1b2c3d4" \
-H "Authorization: Bearer $MT5_API_TOKEN"
POST /experts and the Navigator refresh
MT5 only loads an expert it saw on disk when the terminal started, or that a Navigator refresh has picked up since. A template naming any other .ex5 opens the chart with no expert on it, and the deployment fails with EXPERT_NOT_ATTACHED. So every successful POST /experts, including a re-upload of identical bytes, refreshes the terminal’s Navigator, the same way a user would right-click “Expert Advisors” and choose Refresh. The API does it with window messages sent straight to the Navigator (mt5api/chartctl/winnavigator.py): nothing is typed into whatever window has focus, and it works with no RDP or VNC viewer attached. It then checks that the expert is listed under Expert Advisors > Uploaded. Deploying a host-managed expert for the first time does the same after copying it in.
POST /experts takes the multipart field expert and answers 201 with name, sha256 and size. Uploading identical bytes under an existing name answers 200 with skipped: true; different bytes under an existing name answer 409 EXISTS unless the request carries ?overwrite=true. POST /sets takes the field set, answers 201 with name, sha256 and the parsed inputs, and always replaces a set of the same name, including one a deployment uses. An upload with no file, an empty file, a bad name or a file over max_upload_bytes answers 400 BAD_REQUEST. GET /experts and GET /sets list name, size, sha256, modified_at and source (uploaded, or host for files under assets/).
The response’s navigator_refresh says how that went:
| Value | Meaning |
|---|---|
ok | The expert is in the Navigator; deploy away. already_listed: true means it was there before the refresh (a re-upload or an overwrite), so the check proved nothing new. |
failed | The expert is not in the Navigator after the refresh, or the refresh could not run; navigator_error says why (expert_not_listed, navigator_not_found, backtest_running, internal_error, …). Upload the same file again to retry, or restart the terminal. |
unavailable | No GUI automation on this host (a bare-metal terminal). Restart the terminal before deploying a newly uploaded expert. |
The Navigator’s “Expert Advisors” node and its Refresh item are found by label. English is built in; a terminal running MT5 in another language needs its labels in config.yaml:
chartctl:
navigator_labels:
expert_advisors: ["<the node's label, as the Navigator shows it>"]
refresh: ["<the Refresh item's label in its right-click menu>"]
POST /deployments
| Field | Required | Description |
|---|---|---|
expert | yes | .ex5 filename (previously staged via POST /experts) |
set | no | .set filename (previously staged via POST /sets); without one the expert runs on its default inputs |
symbol | yes | e.g. EURUSD |
timeframe | yes | M1 M2 M3 M4 M5 M6 M10 M12 M15 M20 M30 H1 H2 H3 H4 H6 H8 H12 D1 W1 MN1 |
enabled | no | false declares the deployment paused; default true |
Example response (202):
{
"id": "dep_a1b2c3d4",
"status": "pending",
"deployment": {
"id": "dep_a1b2c3d4",
"expert_file": "HappyGoldScalp.ex5",
"expert_name": "HappyGoldScalp",
"set_file": "gold-m5.set",
"symbol": "XAUUSD",
"timeframe": "M5",
"enabled": true,
"created_at": "2026-07-27T12:00:00Z",
"updated_at": "2026-07-27T12:00:00Z"
}
}
Deploying a host-managed expert from assets/ copies it into the terminal first, and the response then also carries the navigator_refresh fields.
| Status | code | Meaning |
|---|---|---|
| 400 | BAD_REQUEST | A missing or bad field, or an unknown timeframe |
| 404 | ARTIFACT_NOT_FOUND | The expert or set is not staged |
| 409 | DUPLICATE_CHART | Another enabled deployment already runs on this symbol and timeframe |
| 500 | TPL_GENERATION_FAILED | The chart template could not be built; nothing is kept |
Deployment status, derived on every read:
| Status | Meaning |
|---|---|
pending | Declared; the loader has not confirmed it yet |
running | The loader reports the expert live on a chart |
degraded | The loader last reported it running, but observed.json is older than observed_stale_after |
failed | The loader reported an error for it (error carries code and detail, e.g. EXPERT_NOT_ATTACHED); the loader retries every 60 seconds |
paused | enabled is false |
GET /deployments and the other deployment routes
GET /deployments merges the registry with the loader’s report:
{
"revision": 5,
"applied_revision": 5,
"converged": true,
"observed_stale": false,
"loader": {"name": "MT5ChartLoader", "version": "1.0.5",
"last_loop": "2026-07-27 12:00:03", "applied_revision": 5},
"deployments": [
{"id": "dep_a1b2c3d4", "status": "running",
"desired": {"id": "dep_a1b2c3d4", "expert_file": "HappyGoldScalp.ex5",
"expert_name": "HappyGoldScalp", "set_file": "gold-m5.set",
"symbol": "XAUUSD", "timeframe": "M5", "enabled": true,
"created_at": "2026-07-27T12:00:00Z",
"updated_at": "2026-07-27T12:00:00Z"},
"observed": {"id": "dep_a1b2c3d4", "status": "running", "chart_id": 133039117},
"error": null}
]
}
converged is true when every deployment is running or paused, the loader has applied the current revision, and its report is fresh.
GET /deployments/<id>answers one item from that list plusrevisionandobserved_stale, or 404NOT_FOUND.PATCH /deployments/<id>takessetand/orenabled."set": nullor""removes the set. It answers{"id", "deployment"}. An empty body answers 400, an unknown id 404NOT_FOUND, a set that is not staged 404ARTIFACT_NOT_FOUND, and resuming onto a busy symbol and timeframe 409DUPLICATE_CHART.DELETE /deployments/<id>answers{"deleted": "<id>", "was": {...}}.POST /deployments/reconcilerewritesdesired.jsonunder a new revision and answers 202{"revision": N}. The bundled loader reconciles every second anyway, so this matters mostly for custom loaders.
GET /loader
Returns the resident loader EA’s status:
{
"alive": true,
"observed_stale": false,
"loader": {"name": "MT5ChartLoader", "version": "1.0.5",
"last_loop": "2026-07-27 12:00:03", "applied_revision": 5},
"desired_revision": 5,
"applied_revision": 5
}
alive is true when observed.json exists, names a loader, and is younger than observed_stale_after. When it is false the response adds a hint.
If alive is false, the loader hasn’t started yet. Check that chartctl.enabled is on and the terminal was restarted after provisioning. The first boot compile log lives at logs/compile-chartctl-loader.log inside the VM.
GET /charts
Lists every chart known to the terminal, annotated with which deployment (if any) the loader attributes it to:
{
"loader_alive": true,
"observed_stale": false,
"loader": {"name": "MT5ChartLoader", "version": "1.0.5",
"last_loop": "2026-07-27 12:00:03", "applied_revision": 5},
"auto_trading": true,
"charts": [
{"chart_id": 133039117, "symbol": "XAUUSD", "timeframe": "PERIOD_M5",
"expert": "HappyGoldScalp", "expert_enabled": true,
"deployment_id": "dep_a1b2c3d4"},
{"chart_id": 133039118, "symbol": "EURUSD", "timeframe": "PERIOD_H1",
"expert": "", "expert_enabled": false, "deployment_id": ""}
]
}
expert is the expert’s name without .ex5, and deployment_id is "" for a chart no deployment owns.
Charts with no deployment (e.g. leftover duplicates) can be closed with POST /charts/<id>/close. The standalone loader also cleans up after itself: 30 seconds after it starts it closes the bare charts earlier launches left on its own symbol and timeframe (no expert, no indicators, no drawn objects, no owning deployment), so they never pile up to MT5’s chart limit.
POST /charts/<chart_id>/screenshot
Captures a PNG of the chart from inside the terminal. Returns the raw binary (image/png). No query-string auth. Put the token in the header.
curl -X POST -H "Authorization: Bearer $MT5_API_TOKEN" \
"$MT5_API_URL/charts/133039117/screenshot?width=1280&height=720" -o xauusd-m5.png
width and height default to 1280 and 720.
POST /charts/<chart_id>/close
Sends a close_chart command to the loader and waits for its answer. On success it answers {"closed": 133039117}.
Poll the deployment status to confirm the chart was recreated if expected.
Both chart commands go through the loader’s one-command channel and share these errors:
| Status | code | Meaning |
|---|---|---|
| 400 | BAD_REQUEST | chart_id, width or height is not an integer |
| 409 | LOADER_BUSY | Another command is in flight; retry |
| 504 | LOADER_TIMEOUT | The loader did not answer within command_timeout |
| 502 | CLOSE_REFUSED | Close: the chart is the loader’s own |
| 502 | CLOSE_FAILED, SCREENSHOT_FAILED | MT5 refused; error carries the MT5 error number |
| 502 | LOADER_ERROR | Any other loader failure |
WebRequest allowlist
EAs that call WebRequest() need their target hosts in the terminal’s allowlist (Tools → Options → Expert Advisors → Allow WebRequest for). It’s a dedicated call rather than something on the deploy hot path (most deployments need no URLs):
| Method | Endpoint | Description |
|---|---|---|
GET | /webrequest | Stored allowlist and the last apply result (last_apply) |
PUT | /webrequest | Replace (urls) or patch (add/remove) the allowlist |
POST | /webrequest/apply | Re-apply the stored list now |
POST | /webrequest/verify | Read the list the terminal really holds (VM only) |
curl -H "Authorization: Bearer $MT5_API_TOKEN" "$MT5_API_URL/webrequest"
curl -X PUT "$MT5_API_URL/webrequest" \
-H "Authorization: Bearer $MT5_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"add":["https://api.telegram.org"]}' # or {"urls":[...]} to replace
# -> {"success":true,"urls":[...],"applied_via":"win32:OK"}
curl -X POST "$MT5_API_URL/webrequest/apply" \
-H "Authorization: Bearer $MT5_API_TOKEN" # re-apply current list now
curl -X POST "$MT5_API_URL/webrequest/verify" \
-H "Authorization: Bearer $MT5_API_TOKEN"
# -> {"urls":[...stored...],"live":{"enabled":true,"urls":[...]},"terminal_build":6257}
{"urls": [...]}makes the terminal’s list exactly that, removing every other entry, including ones added by hand in the VM.{"add": [...], "remove": [...]}changes only the URLs named. Every other entry the terminal holds stays, and the list read back becomes the stored list, so URLs added by hand survive restarts too.- Only
http(s)URLs with a host, at most 750 characters (MT5 build 6230 refused a 1120-character one) and no spaces,;or control characters are accepted. Anything else answers 400INVALID_URLwith the offending entries underrejected, and nothing is stored. - A body that is not a JSON object, or that has none of
urls,addandremove, answers 400BAD_REQUEST. POST /webrequest/applyanswers{"success": true, "urls": [...], "applied_via": "...", "rejected": [...]}, or{"success": true, "urls": [], "note": "nothing to apply"}when the stored list is empty.- A failed apply answers with a
code: 409 for a state to wait out (BACKTEST_RUNNING, orTERMINAL_BUSYwhen the terminal shows a dialog such as a login), 503 when the terminal is not running (PID_NOT_FOUND,WINDOW_NOT_FOUND), 500 otherwise. While a backtest runs on the terminal,PUTandverifyanswer 409: the tester runs from the sameterminal64.exe. The background re-apply waits for the run to end. urls,addandremovemust be lists; anything else is a 400, never an empty list.verifyopens the Options dialog for a moment, so a second call within 10 seconds answers 429 withRetry-After. Use it to check an apply, not to poll. On a bare-metal terminal it answers 409UNAVAILABLE, becausecommon.iniis the list there.
Inside the Windows VM every terminal starts with /config:mt5start.ini, and a terminal started that way never writes its settings back, so it forgets the list on every restart (the common.ini codec also cannot decode the lists builds 6238 and later write). So the list is set the way a user would, in Tools > Options > Expert Advisors, but with window messages sent straight to the dialog’s controls (mt5api/chartctl/winoptions.py): nothing is typed into whatever window has focus, no window has to be in front, and it works with no RDP or VNC viewer attached. It takes effect immediately in-session and takes seconds. OK is pressed only when every change took; otherwise the dialog is cancelled and nothing is saved. Because MT5 forgets the list on restart, the API re-applies the persisted list about 25 s after its own process starts, which covers the periodic auto-reboot, and the same delay after every terminal restart it performs itself (the health monitor’s recovery restart, POST /terminal/restart). A terminal that starts again without the API restarting it (a LiveUpdate, a terminal the SDK started after a crash, one started by hand) is noticed within about 10 seconds: the API reconnects to it and re-applies the list after the same settle delay. That re-apply, like POST /webrequest/apply, skips a stored URL the terminal will not take and reports it under rejected instead of leaving the terminal with no list. GET /webrequest shows the running terminal (terminal_pid, terminal_started_at) and stale: true while it has not been given the stored list yet; after 5 minutes stale the API log shows !!! WEBREQUEST LIST NOT RESTORED !!! on every health pass. POST /webrequest/apply is for when that error stays. On a bare-metal terminal where common.ini is the store, it falls back to writing common.ini + restarting.
The desired list is persisted per terminal (Config/webrequest.json); the first call migrates whatever the terminal already has, so manually configured URLs are preserved. Every apply writes its outcome to Config/webrequest_status.json, which GET /webrequest returns as last_apply (at, reason, status, code, rejected, pid, pid_started_at). Since nothing depends on focus, terminals apply their lists in parallel; each terminal’s boot re-apply is still staggered by its port (25 s plus 3 s times the port modulo 10) so one VM’s terminals do not all open their Options dialogs in the same second.