Apps — interactive panels in your agent¶
In short
- Tools return interactive panels that render inline in hosts that support MCP Apps: Claude
Desktop, ChatGPT, VS Code, Cursor and Goose. Other hosts show the same result as data, with
its
ui_url. - 17 apps host 51 panels: evaluation curves, EDA, rules, charts, comparisons, decisions, what-if and more.
- Panels have no network access: every byte reaches them through the host, under the caller's token.
- A panel never acts. Decisions stay with people, in the ParleML app.
parleml ships MCP Apps (the official MCP UI extension, spec 2026-01-26):
tools return interactive panels that render inline in hosts that support them —
Claude Desktop, ChatGPT, VS Code, Cursor, Goose. Hosts without MCP Apps (Claude
Code, terminals) show the same tool result as data, with its ui_url.
Each panel is a compiled slice of the real parleml UI (React + Plotly, inlined
into a self-contained text/html;profile=mcp-app template). Data reaches it
only through the host: the tool result the host hands over, and any further
reads the panel makes as host-mediated tools/call — the same consent and audit
path as the model's own calls. The template declares no network access, so
a panel can only reach what its tools expose.
The apps and their panels¶
A host links a UI to a tool definition (_meta.ui.resourceUri in
tools/list), never to one result — it may load the view before the result
arrives. So each UI tool has exactly one app, and one app may serve several
tools. An app with several panels is a router: it picks the panel from a
discriminator field of the result itself; a panel without a when value is
the app's default and takes every result no other panel claims.
Seventeen apps host 51 panels:
App (ui://parleml/…) |
Linked tools | Routed on | Panels (when) |
Highlights |
|---|---|---|---|---|
evaluation |
get_model_evaluation |
— | evaluation | ROC/PR/calibration + threshold slider (confusion recomputed client-side from confusion_curve); ask the agent for a threshold at the recall shown |
eda |
get_eda |
section |
eda (summary · variables · full) · eda-leakage (leakage) | table stats, alerts, on-demand correlation heatmap; the leakage findings |
rules |
get_rules |
— | rules | filterable IF-THEN rule table with Train and Test numbers |
catalog |
describe_catalog |
— | catalog | table browser → click a table for its profile_table |
chart |
get_chart_data |
chart_kind |
forecast (forecast · actual_vs_predicted · residuals) · explain (model_importance) · pdp (pdp) · anomaly (score_distribution · anomaly_map · top_k_table) · clustering (cluster_map · cluster_sizes · silhouette) · association (network) | per-series forecast reload; importance → PDP; one feature's partial dependence; score / map / top-K toggles, and "why did this row score high?" on the top-K rows; "explain this cluster"; market-basket network; any other kind (e.g. eda.*, mli.calibration) drawn as it came |
insight |
get_insight |
kind |
trust (trust_summary) · recommendation · ranking · ltr · leaderboard · fairness · error-analysis (error_analysis) · drift · case | the 9 traffic-light trust tiles; ranking metrics; the model leaderboard; fairness by group; where the errors concentrate; per-feature PSI; one row's explanation, and "why did it score as it did?" |
resource |
get_resource, get_capabilities |
resource_kind |
cockpit (experiment) · plan-record (plan) · resource (default) | an experiment's cockpit — status, trust grade, what can be explained; a stored plan in the plan view; any other resource as a detail card |
run |
get_run |
view |
run-summary (summary) · run-log (log) | stage progress and the Run-all chain; the console tail |
signal |
probe_dataset |
probe |
signal · forecast-signal · baseline-floor · temporal-split | the signal verdict and drivers; forecastability vs seasonal-naive; the trivial-predictor floor; the time split, with a test-share slider that re-runs the probe |
compare |
compare |
compare_kind |
compare-experiments (experiments) · compare-models (models) · compare-datasets (datasets) | metrics side by side with each one's best run, the experiment of the model in production marked, the config diff, top features; model lineage; per-feature PSI |
decision |
simulate_decision |
simulation |
business-value (business_value · reco_business_value · ltr_business_value) · multiclass-cost · decision-layer · ranking-at · decision (default) | expected value across thresholds and the recommended points; cost-matrix results; calibrator candidates; ranking at a discount; the anomaly threshold curve — and that nothing was applied |
what-if |
what_if |
— | what-if | the prediction for the row asked about, with its drivers; edit the values to score the row again, and ask the agent why the prediction moved |
plan |
run_experiment |
action |
plan (plan) · run-status (default) | a plan's verdict, errors, warnings, estimate and resolved settings; a launch / abort / replicate / explain / autodoc / publish status card |
recipe |
author_recipe |
action |
recipe-preview (preview) · recipe (default) | the preview grid; the recipe's SQL, target and latest iteration; the dataset a materialise produced |
prepare |
prepare_data |
action |
prepare-findings (leakage · triage) · prepare-columns (columns) · prepare-eda (eda) | leakage findings with each acknowledgement's reason; the column roles; the EDA summary |
scoring |
score_batch |
action |
scoring-jobs (list) · scoring-evaluation (evaluation) · scoring-ranking (ranking) · scoring-rows (rows) · scoring-job (default) | a scoring job's status and rows scored (submit · status · cancel); an experiment's jobs; a finished labelled job's evaluation and ranking; a page of scored rows |
model-card |
register_model |
— | model-card | the registered model — stage, lineage — and that release is a person's decision |
Panels receive the call's arguments as well as its result (tool-input), so a
panel can title itself by the target it was asked about, re-run its probe,
show the row a what-if was asked about, mark the settings a plan was asked
for, or open the EDA heatmap for the right dataset.
Render-only tools. A panel never acts: it may call read tools through the
host for more detail, and permitted bounded compute calls as described below.
It never applies changes to workspace objects. The builder tools
(including score_batch) and simulate_decision open their apps but no panel
may call them. They are in no panel's allowlist — RENDER_ONLY_TOOLS in
apps/web/src/embed/types.ts, written to the manifest as renderOnlyTools,
the one list the server reads — and the server declares each of them, and
every other builder tool, _meta.ui.visibility: ["model"], so a host refuses
a panel's call too; it refuses to serve a manifest whose allowlist names one.
A panel's allowlist names exactly the tools it calls. A panel's compute calls
only re-run the call that opened it: the temporal-split slider re-runs its
bounded probe, and the what-if panel scores an edited row again (what_if).
A person's decision is never taken inside a widget: the host relays a panel's
calls, so a button there is not proof of a person.
Two views of one result. Hosts disagree on what the model reads: the MCP
Apps spec gives it the text content, Claude Code gives it the
structuredContent whenever there is one. So a result with two views carries
the model's digest in both, and the panel's whole payload in the result's
_meta["parleml/panel"] — which reaches the View with the tool result and is
never model context. Where the model's view is a digest — the 20 most drifted
features of a dataset comparison, the first rows of a long insight, the first
rules of a long rule set or traces of a long chart, an evaluation's curves
thinned to 20 points each with the confusion table at review budgets (top 1 …
50 % of rows), a signal check without its evaluation's curves and with as much
of a wide dataset's column ledger as fits, a catalog's columns with three short
samples, an AutoDoc card without its figures and long rule lists, an EBM's
glass box as each curve's and grid's extremes — the panel's
copy carries everything, plus what it needs to render
(the metric catalog that labels metrics and says which way is better, for the
comparisons, the experiment cockpit, the leaderboard and the ranking
summaries).
The panel's copy has a budget too, 1,000,000 characters
(model_view.PANEL_BUDGET_CHARS): a CLI host prints a whole tool result as one
line of its event stream, and a copy of many megabytes can end an agent step.
A larger copy is cut by the same backstop as the model's view
— a list keeps its leading entries, a curve evenly spaced points — and says so
in its cut_to_fit and note; the UI page (ui_url) has the whole.
A result no panel claims — or one a panel's parser rejects — is shown honestly
rather than guessed at, with the reason as a caption: a chart the chart app
has no panel for (e.g. eda.*) renders as a plain chart, an unavailable
insight shows the backend's note, and anything else says there is no
interactive view yet (the agent has the full result).
The View lifecycle¶
Panels use the official App client (@modelcontextprotocol/ext-apps), so the
handshake is the standard one:
- the panel sends
ui/initializeand receives the host's context and capabilities (the panels use its theme); - it confirms with
ui/notifications/initialized; - the host delivers
ui/notifications/tool-input, thenui/notifications/tool-result(ortool-cancelled).
After that the panel follows host-context-changed (it re-themes live),
reports its size (ui/notifications/size-changed), answers
ui/resource-teardown, and opens Open in parleml through ui/open-link
where the host
offers it (a sandboxed frame usually may not open windows itself). Where the
host takes them, a panel hands a question back to the agent (ui/message:
"Explain cluster C2", "Why did row 17 score as anomalous?", "Propose a
threshold for 80% recall") and keeps the model told what the user has
selected — a threshold, a cluster, a row, a feature
(ui/update-model-context). It can ask to go fullscreen, where its charts
draw taller, and inline it keeps to the container height the host gives.
The panel reads _meta["parleml/panel"] first, then structuredContent; where
a host strips both, it reads the result's JSON text — the model's view, which
is the whole result for most tools and the digest for the few above.
Only the embedding host window is trusted: the panel talks to window.parent
alone and drops any message whose event.source is another window (the SDK's
PostMessageTransport).
How they're built (exposable by design)¶
Details: the embed build
Panels live in apps/web/src/embed/. Any read-only analytical panel becomes
part of an app by:
- programming against the narrow
AnalystReadProvider(props in, reads out — mutations don't typecheck; a panel's props name the tools it calls, and it is handed only those reads), and - listing it in its tool's app in
src/embed/registry.ts, then binding it to its payload parser in that app's module,src/embed/apps/<app>.ts.
npm run build:embed generates each app's entry from one template and
compiles it into a self-contained template under
parleml_mcp/parleml_mcp/apps_assets/ (+ a generated apps_manifest.json,
stamped with the MCP Apps contract it targets and a build digest that every
template carries in its parleml-apps-build meta tag). The MCP server
registers the ui:// resources and the tool links from that manifest at
startup: it refuses to start on a manifest stamped for another contract, and
refuses to serve a template from another build. An app whose template was not
built — or is stale — links nothing. Templates are read from disk when a host
asks for one, never held in memory.
How it is gated¶
Details: the registry, contract and conformance checks
npm run check:embedvalidates the registry structurally, without a build: one app per tool, every panel in an app of its own tools, router panels each claiming distinct discriminator values with at most one default, each app's bindings module present (it binds exactly the app's panels, or it does not typecheck) and importing neither the SPA's API client, react-query nor the router (walked through its static imports), no allowlist naming a render-only tool or a compute tool that did not open the panel — and the committedapps_manifest.jsonequal to what the registry emits, so an app change without its manifest fails.npm run build:embedrefuses a template whose module graph includes the SPA client, or whose output contains the client's request marker: a panel reads only through the host.- The MCP contract tests (
parleml_mcp/test_mcp_contract.py) check the wire shape a host reads —_meta.ui.resourceUrion each linked tool,_meta.ui.visibility: ["model"]on each model-only tool,text/html;profile=mcp-appand the no-origin CSP on each resource, the routing discriminators — and that no panel may call a model-only tool, on every run, over the tracked manifest. - Host conformance (
npm run test:apps, afterbuild:embed): a real browser runs every panel through the real MCP server and the official host-sideAppBridge, in a sandboxed iframe under the spec's default CSP, and requires each to reach "rendered" with its own panel (see below). - Real hosts: the host checklist (
parleml_mcp/docs/internal/hosts.md), before each release.
The templates are gitignored build output (~5 MB each), so they are not byte-checked; the component source is the single source of truth.
The host conformance test¶
Details: the conformance suite and its fixtures
apps/web/e2e-apps/ holds a minimal host page and a Playwright spec;
parleml_mcp/conformance/serve.py serves the real MCP server over HTTP against a
stand-in backend that replays recorded backend responses
(parleml_mcp/conformance/fixtures/backend.json.gz), so the test needs no
training and no data root. The panel set comes from the apps manifest: there
must be exactly one scenario per manifest panel, in
parleml_mcp/conformance/scenarios.json (generated, see below). Each scenario
must:
- link its tool to the expected app (
_meta.ui.resourceUri) with thetext/html;profile=mcp-appMIME type; - complete the lifecycle and render its own panel (
data-embed-state=rendered,data-embed-panel= the panel) — not an error, not the fallback; - show its data: every part the scenario lists (
shows) is on screen — the parts a broken payload, a dropped tool input or a failed follow-up read would leave out; - report its size, and answer the host's
ui/resource-teardown; - log no console error and no uncaught exception — a Content-Security-Policy
refusal (the host policy withholds
evalandblob:), a failed read, a React crash.
Every read tool that opens no panel answers its default call too. Two more
checks cover a live theme change and Open in parleml going through
ui/open-link.
npm run build:embed
npm run test:apps # PARLEML_PYTHON overrides `uv run python`
To re-record the fixtures (after a backend payload change or a new panel), seed
a scratch backend — auth off, an empty data root — with
scripts/seed_mcp_apps_demo.py --api <url> (see the host checklist, parleml_mcp/docs/internal/hosts.md).
It writes its summary outside the repo, to ~/.parleml/mcp-apps-demo/seed.json:
the call that opens each panel, one default call per read tool that opens no
panel, and the builder token. Generate the scenarios from it
(python parleml_mcp/conformance/scenarios.py --seed <the summary>; the panels
come from the apps manifest and what each must show from SHOWS in
scenarios.py — a new panel is one line there plus its call in the seed), then
run the suite with
PARLEML_CONFORMANCE_RECORD=<url> and PARLEML_CONFORMANCE_TOKEN=<builder_token>.
The stand-in backend forwards to the live one — the token, Idempotency-Key,
X-Parleml-Client, and PUT / PATCH included — and stores
what came back. A recording starts empty, so the fixture holds exactly this
run's exchanges, and every response is scrubbed before it is stored or served:
paths under the backend's data root become <data_root>/…, any other local path
<local_path>, and AutoGluon's system-info block (OS, CPU, memory, disk) is
dropped; conformance/test_recording_privacy.py fails on anything that slips
through. The token travels in the host's proxy, never in a URL, the page or the
fixture. conformance/test_response_budget.py then replays every recorded
call — read, compute and builder — and holds both the text content and the
structuredContent to 25,000 characters (model_view.BUDGET_CHARS), and the
panel's copy under _meta to 1,000,000 (model_view.PANEL_BUDGET_CHARS).
Security¶
Templates declare zero network origins: all data arrives through the host,
so a panel is bounded by the caller's token and scope exactly like a direct tool
call, and no token ever enters the iframe. Each resource declares its
_meta.ui.csp with empty connectDomains, resourceDomains and
frameDomains — no network egress, no external resources, no nested frames —
and the templates' own policy grants nothing beyond it. Data-derived strings
render as text, never markup. See the security model.
Next¶
- Security model: the trust boundary panels sit inside.
- Tool catalog: which tool opens which app.