Skip to content

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:

  1. the panel sends ui/initialize and receives the host's context and capabilities (the panels use its theme);
  2. it confirms with ui/notifications/initialized;
  3. the host delivers ui/notifications/tool-input, then ui/notifications/tool-result (or tool-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:

  1. 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
  2. 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:embed validates 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 committed apps_manifest.json equal to what the registry emits, so an app change without its manifest fails.
  • npm run build:embed refuses 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.resourceUri on each linked tool, _meta.ui.visibility: ["model"] on each model-only tool, text/html;profile=mcp-app and 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, after build:embed): a real browser runs every panel through the real MCP server and the official host-side AppBridge, 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 the text/html;profile=mcp-app MIME 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 eval and blob:), 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