You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ms...@apache.org on 2026/07/30 21:38:38 UTC

(superset) 01/01: feat(canvas): add Canvas v2 declarative dashboard prototype

This is an automated email from the ASF dual-hosted git repository.

msyavuz pushed a commit to branch msyavuz/feat/canvas-v2
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 8b2f140eb63789f1aaff97e57eaa5ac3cc528dfe
Author: Mehmet Salih Yavuz <sa...@proton.me>
AuthorDate: Fri Jul 31 00:38:18 2026 +0300

    feat(canvas): add Canvas v2 declarative dashboard prototype
    
    Introduces CDL (Canvas Definition Language), a declarative component tree
    rendered by a new frontend runtime, plus the backend model, REST API,
    migration, and MCP tools for generating and patching canvases.
    
    - superset/models/canvas.py, superset/canvas/: model, API, filters
    - superset/mcp_service/canvas/: generate/get/update/schema tools, patch ops,
      CDL validation
    - superset-frontend/src/Canvas/: renderer, runtime, resolver, viz adapters,
      action dispatch, validator
    - superset-frontend/src/pages/Canvas{,List}/: routes and list view
---
 .../src/Canvas/CanvasRenderer.stories.tsx          |  41 ++
 .../src/Canvas/CanvasRenderer.test.tsx             | 115 ++++
 superset-frontend/src/Canvas/CanvasRenderer.tsx    | 188 ++++++
 superset-frontend/src/Canvas/CanvasViewer.tsx      | 131 ++++
 superset-frontend/src/Canvas/DESIGN.md             | 325 +++++++++
 superset-frontend/src/Canvas/EchartsViz.tsx        | 244 +++++++
 superset-frontend/src/Canvas/FilterControl.tsx     | 126 ++++
 superset-frontend/src/Canvas/SupersetChartViz.tsx  | 204 ++++++
 superset-frontend/src/Canvas/actions.ts            | 113 ++++
 superset-frontend/src/Canvas/catalog.ts            | 191 ++++++
 superset-frontend/src/Canvas/cdl.test.ts           | 306 +++++++++
 .../src/Canvas/fixtures/salesCanvas.ts             | 125 ++++
 superset-frontend/src/Canvas/index.ts              |  28 +
 superset-frontend/src/Canvas/manifest.tsx          | 468 +++++++++++++
 superset-frontend/src/Canvas/queryRunner.ts        | 118 ++++
 superset-frontend/src/Canvas/resolve.ts            | 359 ++++++++++
 superset-frontend/src/Canvas/runtime.tsx           | 314 +++++++++
 superset-frontend/src/Canvas/style.ts              | 177 +++++
 superset-frontend/src/Canvas/types.ts              | 189 ++++++
 superset-frontend/src/Canvas/validator.ts          | 326 +++++++++
 superset-frontend/src/pages/Canvas/index.tsx       |  28 +
 superset-frontend/src/pages/CanvasList/index.tsx   | 121 ++++
 superset-frontend/src/views/routePaths.ts          |   2 +
 superset-frontend/src/views/routes.tsx             |  12 +
 superset/canvas/__init__.py                        |  16 +
 superset/canvas/api.py                             |  70 ++
 superset/canvas/filters.py                         |  59 ++
 superset/initialization/__init__.py                |  11 +
 superset/mcp_service/app.py                        |   6 +
 superset/mcp_service/canvas/__init__.py            |  16 +
 superset/mcp_service/canvas/patch.py               | 187 ++++++
 superset/mcp_service/canvas/schemas.py             | 139 ++++
 superset/mcp_service/canvas/tool/__init__.py       |  28 +
 .../mcp_service/canvas/tool/generate_canvas.py     | 121 ++++
 superset/mcp_service/canvas/tool/get_canvas.py     |  73 ++
 .../mcp_service/canvas/tool/get_canvas_schema.py   |  55 ++
 superset/mcp_service/canvas/tool/update_canvas.py  | 119 ++++
 superset/mcp_service/canvas/utils.py               |  33 +
 superset/mcp_service/canvas/validation.py          | 734 +++++++++++++++++++++
 ...6-07-24_00-00_c4a1b2d3e5f6_add_canvas_tables.py |  78 +++
 superset/models/canvas.py                          |  48 ++
 superset/subjects/models.py                        |  38 ++
 superset/views/canvas.py                           |  41 ++
 superset/views/core.py                             |  25 +
 tests/unit_tests/mcp_service/canvas/__init__.py    |  16 +
 .../mcp_service/canvas/test_canvas_patch.py        | 151 +++++
 .../mcp_service/canvas/test_canvas_validation.py   |  81 +++
 47 files changed, 6396 insertions(+)

diff --git a/superset-frontend/src/Canvas/CanvasRenderer.stories.tsx b/superset-frontend/src/Canvas/CanvasRenderer.stories.tsx
new file mode 100644
index 00000000000..5ec138ec9eb
--- /dev/null
+++ b/superset-frontend/src/Canvas/CanvasRenderer.stories.tsx
@@ -0,0 +1,41 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Meta, StoryObj } from '@storybook/react-webpack5';
+import { CanvasRenderer } from './CanvasRenderer';
+import { salesCanvas, createMockRunner } from './fixtures/salesCanvas';
+
+export default {
+  title: 'Canvas/CanvasRenderer',
+  component: CanvasRenderer,
+} as Meta<typeof CanvasRenderer>;
+
+type Story = StoryObj<typeof CanvasRenderer>;
+
+/** Change the Region select (or hit Reset) and watch the bound chart re-query. */
+export const InteractiveCanvas: Story = {
+  render: () => (
+    <div style={{ maxWidth: 720 }}>
+      <CanvasRenderer
+        definition={salesCanvas}
+        queryRunner={createMockRunner(300)}
+      />
+    </div>
+  ),
+};
diff --git a/superset-frontend/src/Canvas/CanvasRenderer.test.tsx b/superset-frontend/src/Canvas/CanvasRenderer.test.tsx
new file mode 100644
index 00000000000..9a8b158f7ee
--- /dev/null
+++ b/superset-frontend/src/Canvas/CanvasRenderer.test.tsx
@@ -0,0 +1,115 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+  render,
+  screen,
+  userEvent,
+  waitFor,
+} from 'spec/helpers/testing-library';
+import { CanvasRenderer } from './CanvasRenderer';
+import { CanvasDefinition, CdlQueryContext } from './types';
+import type { QueryRunner } from './runtime';
+import { QueryResult } from './resolve';
+
+// echarts touches canvas APIs jsdom lacks; the reactive loop under test is the
+// query dispatch, not the render, so stub the engine.
+jest.mock('echarts/core', () => ({
+  use: jest.fn(),
+  registerTheme: jest.fn(),
+  init: jest.fn(() => ({
+    setOption: jest.fn(),
+    resize: jest.fn(),
+    dispose: jest.fn(),
+  })),
+}));
+
+const definition: CanvasDefinition = {
+  cdlVersion: 2,
+  variables: { region: { type: 'string', default: 'APAC', scope: 'query' } },
+  tree: {
+    id: 'root',
+    type: 'Column',
+    children: [
+      {
+        id: 'to-emea',
+        type: 'Button',
+        props: { children: 'EMEA' },
+        on: {
+          click: [{ action: 'setVariable', name: 'region', value: 'EMEA' }],
+        },
+      },
+      {
+        id: 'chart',
+        type: 'Viz',
+        renderer: 'echarts',
+        data: {
+          queryContext: {
+            datasetId: 1,
+            metrics: ['SUM(sales)'],
+            groupby: ['month'],
+            filters: [{ col: 'region', op: '==', val: '$region' }],
+          },
+          encoding: { x: 'month', y: 'SUM(sales)' },
+        },
+        option: { series: [{ type: 'line' }] },
+      },
+    ],
+  },
+};
+
+const regionOf = (qc: CdlQueryContext): unknown =>
+  qc.filters?.find(f => f.col === 'region')?.val;
+
+test('a bound query re-runs with the new value when a control changes a variable', async () => {
+  const run = jest.fn(
+    async (_queryContext: CdlQueryContext): Promise<QueryResult> => ({
+      columns: ['month', 'SUM(sales)'],
+      records: [{ month: 'Jan', 'SUM(sales)': 1 }],
+    }),
+  );
+  const runner: QueryRunner = { run };
+
+  render(<CanvasRenderer definition={definition} queryRunner={runner} />);
+
+  // Initial fetch resolves $region to its default.
+  await waitFor(() => expect(run).toHaveBeenCalled());
+  expect(regionOf(run.mock.calls[0][0])).toBe('APAC');
+
+  run.mockClear();
+  await userEvent.click(screen.getByText('EMEA'));
+
+  // The variable change re-runs the bound query with the new value.
+  await waitFor(() => expect(run).toHaveBeenCalled());
+  const lastCall = run.mock.calls[run.mock.calls.length - 1][0];
+  expect(regionOf(lastCall)).toBe('EMEA');
+});
+
+test('an invalid canvas renders validation errors instead of the tree', () => {
+  const broken = {
+    ...definition,
+    tree: { ...definition.tree, children: [{ id: 'bad', type: 'NotAThing' }] },
+  } as CanvasDefinition;
+
+  render(
+    <CanvasRenderer definition={broken} queryRunner={{ run: jest.fn() }} />,
+  );
+
+  expect(screen.getByTestId('canvas-validation-errors')).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/Canvas/CanvasRenderer.tsx b/superset-frontend/src/Canvas/CanvasRenderer.tsx
new file mode 100644
index 00000000000..6315ff79341
--- /dev/null
+++ b/superset-frontend/src/Canvas/CanvasRenderer.tsx
@@ -0,0 +1,188 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useMemo } from 'react';
+import { styled, useTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { CanvasDefinition, CdlFilter, CdlNode, Primitive } from './types';
+import { MANIFEST } from './manifest';
+import { runActions } from './actions';
+import { resolveVars } from './resolve';
+import { resolveStyle } from './style';
+import {
+  FilterProvider,
+  QueryRunner,
+  QueryRunnerProvider,
+  UiStateProvider,
+  VariableProvider,
+  VariableProviderProps,
+  useFilters,
+  useUiState,
+  useVariables,
+} from './runtime';
+import { validateCanvas, ValidationError } from './validator';
+
+const InvalidNode = styled.div`
+  padding: ${({ theme }) => theme.sizeUnit * 2}px;
+  border: 1px solid ${({ theme }) => theme.colorErrorBorder};
+  border-radius: ${({ theme }) => theme.borderRadius}px;
+  color: ${({ theme }) => theme.colorError};
+  font-family: ${({ theme }) => theme.fontFamilyCode};
+  font-size: ${({ theme }) => theme.fontSizeSM}px;
+`;
+
+/** The recursive resolver: catalog lookup, prop/bind/event wiring, recursion. */
+function NodeRenderer({ node }: { node: CdlNode }) {
+  const { vars, decls, setVariable, reset } = useVariables();
+  const { setFilter, clearFilters } = useFilters();
+  const { setActiveTab, setModalOpen, refresh } = useUiState();
+  const theme = useTheme();
+  const entry = MANIFEST[node.type];
+
+  if (!entry) {
+    return <InvalidNode>{t('Unknown node type: %s', node.type)}</InvalidNode>;
+  }
+
+  const resolvedProps = resolveVars(node.props ?? {}, vars);
+
+  const getBound = (prop: string): Primitive | undefined => {
+    const ref = node.bind?.[prop];
+    return ref ? vars[ref.slice(1)] : undefined;
+  };
+
+  const setBound = (prop: string, value: Primitive) => {
+    const ref = node.bind?.[prop];
+    if (!ref) {
+      return;
+    }
+    const name = ref.slice(1);
+    // Controls emit strings (an Input always does). Coerce to the variable's
+    // declared type so a numeric parameter reaches the query as a number.
+    const declared = decls[name]?.type;
+    let next: Primitive = value;
+    if (declared === 'number' && typeof value === 'string') {
+      const parsed = Number(value);
+      next = Number.isFinite(parsed) ? parsed : 0;
+    } else if (declared === 'boolean' && typeof value === 'string') {
+      next = value === 'true';
+    }
+    setVariable(name, next);
+  };
+
+  const fire = (event: string, value?: Primitive) => {
+    runActions(node.on?.[event], {
+      vars,
+      setVariable,
+      // Keyed by node+column so repeated clicks replace rather than accumulate.
+      applyFilter: filter =>
+        setFilter(`${node.id}:${filter.col}`, { filter: filter as CdlFilter }),
+      crossFilter: filter =>
+        setFilter(`${node.id}:${filter.col}`, { filter: filter as CdlFilter }),
+      clearFilters: () => {
+        clearFilters();
+        reset();
+      },
+      navigateTab: (tabsId, tab) => setActiveTab(tabsId, tab),
+      setModalOpen: (modalId, open) => setModalOpen(modalId, open),
+      refresh: () => refresh(),
+      eventValue: value,
+    });
+  };
+
+  const Component = entry.component;
+  const renderNode = (child: CdlNode) => (
+    <NodeRenderer key={child.id} node={child} />
+  );
+  const children = node.children?.map(renderNode);
+
+  return (
+    <Component
+      node={node}
+      resolvedProps={resolvedProps}
+      fire={fire}
+      getBound={getBound}
+      setBound={setBound}
+      renderNode={renderNode}
+      style={resolveStyle(
+        node.style,
+        theme as unknown as Record<string, unknown>,
+      )}
+    >
+      {children}
+    </Component>
+  );
+}
+
+const ErrorList = styled.ul`
+  margin: 0;
+  padding: ${({ theme }) => theme.sizeUnit * 2}px
+    ${({ theme }) => theme.sizeUnit * 4}px;
+  color: ${({ theme }) => theme.colorError};
+  font-family: ${({ theme }) => theme.fontFamilyCode};
+  font-size: ${({ theme }) => theme.fontSizeSM}px;
+`;
+
+function ValidationErrors({ errors }: { errors: ValidationError[] }) {
+  return (
+    <ErrorList data-test="canvas-validation-errors">
+      {errors.map(error => (
+        <li key={`${error.path}:${error.message}`}>
+          <strong>{error.path}</strong>: {error.message}
+        </li>
+      ))}
+    </ErrorList>
+  );
+}
+
+export interface CanvasRendererProps {
+  definition: CanvasDefinition;
+  queryRunner: QueryRunner;
+  dataMaskSink?: VariableProviderProps['dataMaskSink'];
+}
+
+/**
+ * Top-level entry: validate the CDL (hard-reject on failure), then render the
+ * tree inside the query-runner and variable providers.
+ */
+export function CanvasRenderer({
+  definition,
+  queryRunner,
+  dataMaskSink,
+}: CanvasRendererProps) {
+  const validation = useMemo(() => validateCanvas(definition), [definition]);
+
+  if (!validation.valid) {
+    return <ValidationErrors errors={validation.errors} />;
+  }
+
+  return (
+    <QueryRunnerProvider runner={queryRunner}>
+      <UiStateProvider>
+        <FilterProvider>
+          <VariableProvider
+            variables={definition.variables}
+            dataMaskSink={dataMaskSink}
+          >
+            <NodeRenderer node={definition.tree} />
+          </VariableProvider>
+        </FilterProvider>
+      </UiStateProvider>
+    </QueryRunnerProvider>
+  );
+}
diff --git a/superset-frontend/src/Canvas/CanvasViewer.tsx b/superset-frontend/src/Canvas/CanvasViewer.tsx
new file mode 100644
index 00000000000..1d0954531a7
--- /dev/null
+++ b/superset-frontend/src/Canvas/CanvasViewer.tsx
@@ -0,0 +1,131 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useEffect, useState } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { styled } from '@apache-superset/core/theme';
+import { Loading } from '@superset-ui/core/components';
+import { CanvasRenderer } from './CanvasRenderer';
+import { createSupersetQueryRunner } from './queryRunner';
+import { CanvasDefinition } from './types';
+
+const runner = createSupersetQueryRunner();
+
+const Page = styled.div<{ $maxWidth: string }>`
+  /* width:100% so the page fills the app's flex main area instead of
+     collapsing to its content width. The cap is canvas-controlled
+     (envelope.canvasWidth); default 'full' → no cap, matching dashboards. */
+  width: 100%;
+  max-width: ${({ $maxWidth }) => $maxWidth};
+  margin: 0 auto;
+  padding: ${({ theme }) => theme.sizeUnit * 6}px;
+
+  /* There is no global border-box reset in this codebase, so any node that
+     combines width:100% with author-supplied padding would overflow and force
+     horizontal scrolling. Scope the reset to the canvas subtree. */
+  &,
+  & *,
+  & *::before,
+  & *::after {
+    box-sizing: border-box;
+  }
+`;
+
+const Title = styled.h1`
+  font-size: ${({ theme }) => theme.fontSizeXL}px;
+  font-weight: ${({ theme }) => theme.fontWeightStrong};
+  margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px;
+`;
+
+interface CanvasResponse {
+  result: {
+    name?: string;
+    // The REST API returns the CDL as a stored JSON string.
+    definition: CanvasDefinition | string;
+  };
+}
+
+const parseDefinition = (
+  definition: CanvasDefinition | string,
+): CanvasDefinition =>
+  typeof definition === 'string' ? JSON.parse(definition) : definition;
+
+interface ViewerState {
+  loading: boolean;
+  error?: string;
+  title?: string;
+  definition?: CanvasDefinition;
+}
+
+/**
+ * Loads a saved Canvas by id/uuid from the REST API and renders it against real
+ * data. The route element supplies `idOrUuid` from the URL.
+ */
+export function CanvasViewer({ idOrUuid }: { idOrUuid: string }) {
+  const [state, setState] = useState<ViewerState>({ loading: true });
+
+  useEffect(() => {
+    let live = true;
+    setState({ loading: true });
+    SupersetClient.get({ endpoint: `/api/v1/canvas/${idOrUuid}` })
+      .then(({ json }) => {
+        if (!live) return;
+        const { result } = json as unknown as CanvasResponse;
+        setState({
+          loading: false,
+          title: result.name,
+          definition: parseDefinition(result.definition),
+        });
+      })
+      .catch((error: unknown) => {
+        if (!live) return;
+        setState({
+          loading: false,
+          error: error instanceof Error ? error.message : String(error),
+        });
+      });
+    return () => {
+      live = false;
+    };
+  }, [idOrUuid]);
+
+  if (state.loading) {
+    return <Loading />;
+  }
+  if (state.error || !state.definition) {
+    return (
+      <Page $maxWidth="760px">
+        {t('Could not load canvas: %s', state.error ?? 'not found')}
+      </Page>
+    );
+  }
+
+  // Default to full-bleed (like a dashboard); a canvas opts into a narrower
+  // reading measure via envelope.canvasWidth (e.g. "820px").
+  const width = state.definition.canvasWidth;
+  const maxWidth = !width || width === 'full' ? 'none' : width;
+
+  return (
+    <Page $maxWidth={maxWidth}>
+      {state.title && <Title>{state.title}</Title>}
+      <CanvasRenderer definition={state.definition} queryRunner={runner} />
+    </Page>
+  );
+}
diff --git a/superset-frontend/src/Canvas/DESIGN.md b/superset-frontend/src/Canvas/DESIGN.md
new file mode 100644
index 00000000000..ccd7daa38b8
--- /dev/null
+++ b/superset-frontend/src/Canvas/DESIGN.md
@@ -0,0 +1,325 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+# Design: AI-Native Dashboards v2 ("Canvas")
+
+**Status:** Draft for review
+**Working names:** *Canvas* = the v2 artifact · *CDL* (Canvas Definition Language) = the JSON tree it stores. Both are placeholders; rename before implementation.
+
+---
+
+## 1. Goal & framing
+
+Let a user (via AI, or by hand) compose a dashboard as a **typed, declarative component tree** that a **new renderer** walks and draws. The tree can hold three kinds of leaf, rendered uniformly:
+
+- **raw ECharts** blocks (AI authors an ECharts `option` directly — near-arbitrary chart expressiveness)
+- **governed Superset charts** (existing `Slice`, full query-context / RLS / caching / drill)
+- **interactive Superset UI components** (Button, Select, Input, …) for dashboard-side interactivity
+
+The central invariant that keeps this "constrained, not sandpack":
+
+> **Nothing the AI emits is ever code.** Presentation is a data `option`. Data is a declarative `queryContext` + `encoding`. Behavior is a bounded `action` enum. No function string ever crosses the persistence boundary.
+
+Everything expressive comes from *config objects*, never from `eval`. That is the entire safety and maintainability thesis.
+
+### Non-goals (v1)
+- Arbitrary user JS / custom React components (the sandpack path — explicitly excluded).
+- Replacing v1 dashboards. Canvas is additive; the renderer can also be embedded *inside* a v1 dashboard as a single node for incremental adoption.
+- Cross-canvas data flow, server-driven layout, or realtime collaboration.
+
+### Design principles
+1. **Data, not code** (above).
+2. **Reuse Superset's governance spine.** Bound queries run through the *existing* `query_context` path — RLS, datasource access, caching come for free. Reactivity runs through the *existing* `dataMask`/native-filter state.
+3. **One dispatch table.** A single component *manifest* is simultaneously (a) the renderer's type→component map, (b) the prop/event contract, and (c) the schema handed to the AI. No parallel registries drifting apart.
+4. **Typed tree, versioned.** The tree is validated server-side against a schema with an explicit `version`; schema evolution is a real migration, not blob archaeology.
+
+---
+
+## 2. The artifact & persistence
+
+A **Canvas** is a new UUID-keyed model, deliberately separate from `Dashboard`/`Slice`.
+
+```
+canvas
+  id            BIGINT  PK
+  uuid          UUID    (external addressing — AI/agents reference by uuid)
+  slug          VARCHAR unique, nullable
+  title         VARCHAR
+  definition    MEDIUMTEXT   -- the CDL JSON tree (typed, versioned)
+  version       INT          -- CDL schema version (for migrations)
+  created_by / changed_by / owners (FAB audit + M2M owners, like Dashboard)
+  roles         M2M -> ab_role   -- dashboard-style RBAC
+```
+
+- `definition` is the whole CDL tree (§3). We keep it as one JSON blob (like `position_json`) — the *validator*, not the column shape, is what enforces structure.
+- Standard Superset command/DAO/REST stack: `CanvasRestApi` (`@protect()`), `Create/Update/Delete/GetCanvasCommand` (`@transaction()`), `CanvasDAO`, Marshmallow schemas for the OpenAPI envelope. **The CDL body itself is validated by a dedicated CDL validator** (§10), not by Marshmallow field-by-field.
+- Object-level auth: `security_manager.raise_for_access(canvas=...)` on read/mutate, mirroring dashboards (Canvas is a data-bearing resource).
+
+**Migration:** one Alembic migration adds the table using `superset.migrations.shared.utils` helpers.
+
+---
+
+## 3. CDL — the node tree
+
+### 3.1 Envelope
+```jsonc
+{
+  "cdlVersion": 2,
+  "variables": { /* name -> VariableDecl */ },
+  "tree": { /* root Node */ }
+}
+```
+
+### 3.2 Common node fields
+Every node shares:
+```jsonc
+{
+  "id": "n_abc",            // stable id (AI-addressable; used for targeted edits/diffs)
+  "type": "Row",            // manifest key -> component
+  "props": { ... },         // typed, validated against the manifest propSchema
+  "bind": { "propName": "$var" },   // one/two-way binding of a prop to a variable
+  "on":   { "eventName": [ Action, ... ] },  // declarative event handlers
+  "children": [ Node, ... ] // only for container types
+}
+```
+`Viz` nodes add `renderer`, `data`, `option`, `encoding`, `chartId` (§5).
+
+### 3.3 Node categories (v1 allowlist — grows deliberately)
+
+| Category | Types | Notes |
+|---|---|---|
+| Layout | `Column`, `Row`, `Grid`, `Tabs`, `Tab`, `Divider` | containers; recurse over `children` |
+| Viz | `Viz` (renderer: `echarts` \| `supersetChart`) | the two-headed leaf (§5) |
+| Control | `Select`, `Button`, `Input`, `Switch`, `DateRange`, `RadioGroup` | interactive; drive variables via `bind`/`on` |
+| Display | `Markdown`, `Header`, `Text`, `Image` | static presentation |
+
+Control components come straight from `@superset-ui/core/components` via the manifest — no new UI built, just registered and constrained.
+
+---
+
+## 4. Variables, binding & reactivity
+
+### 4.1 Variable declaration
+```jsonc
+"variables": {
+  "region":   { "type": "string", "default": "APAC", "scope": "query" },
+  "activeTab":{ "type": "string", "default": "overview", "scope": "ui" }
+}
+```
+- `scope: "query"` → the variable is **projected onto `dataMask`** (a synthetic native-filter/extra-form-data entry). Changing it invalidates and re-runs every bound query that references it — reusing Superset's existing query-invalidation, caching, and RLS. **This is the confirmed reactive substrate.**
+- `scope: "ui"` → a client-only store value (active tab, toggle open/closed). Never touches the backend; drives props/visibility only.
+
+### 4.2 Reference syntax
+`$region` inside any prop, `queryContext` filter value, or action param is resolved against the variable store at render time. Resolution is **pure substitution of a value** — not expression evaluation. (A later version may add a tiny safe expression grammar; v1 is references only, keeping the injection surface at zero.)
+
+### 4.3 Binding
+```jsonc
+{ "type": "Select", "props": { "options": ["APAC","EMEA","AMER"] }, "bind": { "value": "$region" } }
+```
+Two-way: the Select reads `$region` and, on change, writes it back. For a `query`-scoped variable, that write is a `dataMask` update → bound queries re-run. This is *identical plumbing* to a native filter driving charts; we're just exposing it as an author-composable control.
+
+---
+
+## 5. Viz nodes
+
+### 5.1 `renderer: "supersetChart"` — governed
+```jsonc
+{ "type": "Viz", "renderer": "supersetChart",
+  "chartId": 345,
+  "filters": [ { "col": "region", "op": "==", "val": "$region" } ] }
+```
+Renders the existing `SuperChart` via `chartId`. `filters` merge into the chart's `dataMask` (cross-filter mechanism). Auth: chart access checked at load; RLS applies in the normal query path. Zero new query machinery.
+
+### 5.2 `renderer: "echarts"` — bound + declarative
+```jsonc
+{ "type": "Viz", "renderer": "echarts",
+  "data": {
+    "queryContext": {
+      "datasetId": 12,
+      "metrics": ["SUM(sales)"],
+      "groupby": ["month"],
+      "filters": [ { "col": "region", "op": "==", "val": "$region" } ]
+    },
+    "encoding": { "x": "month", "y": "SUM(sales)", "series": null }
+  },
+  "option": {
+    "xAxis": { "type": "category" },
+    "yAxis": { "type": "value" },
+    "series": [ { "type": "line" } ],
+    "tooltip": { "formatter": { "kind": "currency", "currency": "USD" } }
+  }
+}
+```
+
+**Pipeline (all in the renderer):**
+1. Resolve `$vars` in `data.queryContext`.
+2. Run it through the **existing** query-context endpoint (`POST /api/v1/chart/data`-equivalent). → governed, cached, RLS-enforced result set. **The dataset filter values are parameterized query-context filters, never string-concatenated SQL — `$region` is a *value*, not a fragment. No injection surface.**
+3. Map the result columns into `option.series[].data` using `encoding` (`x`→category dim, `y`→series value, optional `series` field → one series per group).
+4. **Resolve formatters** from the declarative vocabulary into real functions *client-side, at render*: `{ kind: "currency"|"number"|"percent"|"date"|"template", ... }`. **A string-valued `formatter` (or any `function`/`=>` anywhere in `option`) is rejected by the validator (§10).** This is the hard line between "config" and "code."
+
+`encoding` is intentionally small in v1 (`x`, `y`, `series`, `size`, `color`). It covers the common cartesian/pie cases; anything exotic can inline pre-shaped `series.data` via a `query`-bound transform in a later version.
+
+---
+
+## 6. Action vocabulary (behavior, bounded)
+
+Event handlers (`on.click`, `on.change`, …) hold an **ordered list of declarative actions** from a closed enum. No arbitrary handlers, ever.
+
+| Action | Params | Effect |
+|---|---|---|
+| `setVariable` | `name`, `value` (may be `$other` or an event value) | write the variable store |
+| `applyFilter` / `crossFilter` | `col`, `op`, `val` | push a `dataMask` filter |
+| `clearFilters` | `scope?` | reset dataMask/vars |
+| `navigateTab` | `tabsId`, `tab` | switch a `Tabs` node's active tab (ui-scoped var) |
+| `openUrl` | `url`, `newTab?` | navigate; **`http(s)` only, `javascript:`/`data:` rejected by validator** |
+| `refresh` | `target?` | force re-query a Viz / the whole canvas |
+
+Adding a capability = adding an entry here (with a typed param schema), not opening a code hole. That's the maintainability property.
+
+---
+
+## 7. Component manifest (the one dispatch table)
+
+The gap the codebase scan found: `@superset-ui/core/components` has no machine-readable registry. We build one — it is the renderer's dispatch table **and** the AI-facing contract.
+
+```ts
+interface ManifestEntry {
+  name: string;                       // CDL "type"
+  component: React.ComponentType<any>; // resolved React component (or a Viz sentinel)
+  category: 'layout' | 'viz' | 'control' | 'display';
+  propSchema: JSONSchema;             // validates node.props; also handed to the AI
+  events?: EventSpec[];               // which on.* events this type emits + their payload shape
+  bindableProps?: string[];           // which props accept $var / two-way bind
+  container?: boolean;                // accepts children
+}
+type ComponentManifest = Record<string, ManifestEntry>;
+```
+
+- **Allowlist is the manifest's key set** — a `type` not in the manifest fails validation and never renders. No dynamic/arbitrary component resolution.
+- Mirrors the two existing string→component registries (dashboard `componentLookup`, `ChartComponentRegistry`) — same proven pattern, extended to general UI.
+- `propSchema`s are authored once and consumed three ways (validate, render, prompt). A build step can seed them from the components' TS prop types to reduce drift, but the manifest is the source of truth.
+- **Backend needs the schema too** (server-side validation, MCP tool). Ship the manifest's *schema half* (names + propSchema + events, no React) as a JSON artifact importable by Python — one generated file, single source, no hand-synced dual registry.
+
+---
+
+## 8. Reactive substrate — dataMask mapping (detail)
+
+Confirmed approach: **variables project onto `dataMask`.**
+
+- On load, each `query`-scoped variable seeds a synthetic `dataMask` entry (`extraFormData` filter).
+- A `bind`/`setVariable`/`applyFilter` write dispatches the existing dataMask update action.
+- Every bound `Viz` subscribes to dataMask; a change to a referenced variable invalidates its query cache key → re-fetch, exactly as native charts behave.
+- **Governance inheritance:** because bound queries are ordinary query-contexts, RLS rules, datasource access checks, row limits, and cache all apply unchanged. We add *no* new query authorization path.
+- `ui`-scoped variables live in a lightweight React context (or a slice of the canvas store) and never enter dataMask — they only gate props/visibility/active-tab.
+
+Net: the *only* genuinely new runtime is the tree-walking renderer + the encoding/formatter mapper. Data governance and reactivity are borrowed wholesale.
+
+---
+
+## 9. Renderer architecture
+
+A new component, `CanvasRenderer`, analogous to `DashboardBuilder` + `DashboardComponent`:
+
+```
+CanvasRenderer(definition)
+  ├─ VariableProvider (seeds store; projects query-scoped vars onto dataMask)
+  └─ NodeRenderer(node)                         // the recursive resolver
+       ├─ manifest[node.type]  ──► ManifestEntry (else: invalid-node boundary)
+       ├─ resolve props: substitute $vars, apply bind
+       ├─ wire on.* -> ActionRunner (bounded dispatch)
+       ├─ if container: map children -> NodeRenderer
+       └─ if Viz:
+            ├─ supersetChart -> <SuperChart chartId .../>
+            └─ echarts -> useBoundQuery(queryContext) -> encode -> resolveFormatters -> <ECharts option/>
+```
+
+- Each node gets an error boundary (a broken node degrades locally, like `ChartHolder` today).
+- **Embeddable:** register a `CANVAS` node `type` in the v1 dashboard `componentLookup` so a Canvas can live inside a legacy dashboard — the incremental-adoption path.
+- `useBoundQuery` wraps the existing chart-data fetch + Redux cache; it is the *only* data-fetch primitive the renderer introduces.
+
+---
+
+## 10. Validation (the enforcement point)
+
+A single **CDL validator** (shared shape, TS in the renderer + generated JSON-schema for Python) runs on every save (command layer) and on AI output. It enforces:
+
+1. **Structural** — valid node `type`s (in manifest), valid nesting (containers only have children), required fields per `renderer`.
+2. **Prop** — `node.props` validates against `manifest[type].propSchema`.
+3. **No-code invariant** — reject any string that is a function (`function`, `=>`, `new Function`) or any `formatter`/callback that isn't a declarative formatter object; reject `javascript:`/`data:` URLs in `openUrl`.
+4. **Action** — every action name in the enum; params match the action's schema.
+5. **Binding** — `bind` only targets `bindableProps`; every `$var` referenced is declared in `variables`.
+6. **Reference integrity** — `chartId`/`datasetId` exist and are accessible to the actor (deferred to command-layer auth via `raise_for_access`).
+
+Validation failure = hard reject with a structured error path (so the AI can self-correct — same pattern as the existing `generate_chart` 5-layer validation).
+
+---
+
+## 11. Security model (maps to SECURITY.md)
+
+- **Resource class:** Canvas is data-bearing (dashboard-like) → route-level `@protect()` **and** object-level `security_manager.raise_for_access(canvas=...)`; owners/roles M2M for scoping. In scope for the authorization matrix exactly like dashboards.
+- **Referenced charts/datasets:** access enforced at load via the existing `raise_for_access` on the chart/datasource; bound `queryContext` runs the normal governed path (RLS, DB access). No new data authorization surface.
+- **No-code invariant** (validator §10) is the primary novel control: model/AI output cannot introduce executable code, so there is no new XSS/RCE vector beyond what markdown/links already allow.
+- **`openUrl`** restricted to `http(s)`; equivalent to today's markdown links (authors are Alpha+; viewers see only what an author with dashboard-edit capability could already put in front of them). Not a privilege escalation.
+- **Variable→query interpolation** is value substitution into *parameterized* query-context filters, never SQL string assembly → no SQLi surface introduced.
+- **Principal note for any future finding:** author = a role with chart/dashboard-create capability (Alpha+ or explicit grant); viewer = whatever role the Canvas RBAC grants. A finding must name the matrix row + principal per repo policy.
+
+---
+
+## 12. AI authoring flow (MCP)
+
+Extends the existing `mcp_service` pattern (which already emits typed configs + validates + compile-checks):
+
+- `get_canvas_schema` — returns the manifest schema half + action/encoding/formatter grammar (the contract the model targets). Mirrors existing `get_chart_type_schema`.
+- `generate_canvas` — model emits a CDL tree → **validator (§10) runs → structured errors fed back for self-correction** → optional preview (dry render + dry-run each bound query) → `save=false` by default, mirroring `generate_chart`'s preview-first/compile-check discipline.
+- `update_canvas` — targeted edits by node `id` (why nodes carry stable ids): patch/replace/move a subtree; re-validate.
+- Reuses the existing chart tools for `supersetChart` leaves (author a governed chart, drop its id into the tree).
+
+---
+
+## 13. Schema versioning
+
+- `cdlVersion` in the envelope; `canvas.version` column.
+- Forward migrations are pure functions `migrate_v{n}_to_v{n+1}(tree)`, run lazily on read and/or in a batch Alembic data migration — feasible precisely because the tree is typed (contrast: migrating free-form `position_json`).
+- The manifest is versioned alongside; a removed component type gets a migration to a replacement or a placeholder node.
+
+---
+
+## 14. Delivery plan (thin vertical slice first)
+
+1. **Contract** — CDL schema + validator (TS) + generated JSON-schema (Python). Envelope, common fields, `Viz`, `Column/Row`, `Select`, `Button`. *No renderer yet — validate hand-written fixtures.*
+2. **Manifest** — the 4-component manifest + the schema-export build step.
+3. **Backend** — `canvas` model + migration + REST/command/DAO + `raise_for_access` + save-time validation.
+4. **Renderer** — `CanvasRenderer`/`NodeRenderer`, variable store, dataMask projection, layout + control nodes. Prove: a `Select` bound to `$region` updates a `supersetChart` leaf via dataMask. *(reactive loop proven with governed charts only)*
+5. **ECharts bound path** — `useBoundQuery` + `encoding`→`option` mapper + formatter resolver + no-code validation. Prove: a raw-echarts leaf re-queries on `$region` change.
+6. **Actions** — `ActionRunner` + full v1 enum.
+7. **MCP** — `get_canvas_schema` / `generate_canvas` / `update_canvas`.
+8. **Embed** — `CANVAS` node in v1 dashboard `componentLookup`.
+
+Each step is independently shippable/testable; steps 4–5 are the proof-of-concept milestone.
+
+---
+
+## 15. Open questions
+
+1. **`encoding` ceiling** — how far do we push declarative encoding before allowing a bound "shape the series" transform? v1 stays cartesian/pie; revisit once real charts stress it.
+2. **Multi-query Viz** — a single echarts block needing >1 query (e.g. overlay). Defer, or allow `data` to be an array of named queries?
+3. **Theming** — echarts blocks must consume the Superset antd theme tokens (not hardcode colors) so light/dark works. Renderer injects a themed base `option`; authors override sparingly.
+4. **Cross-filtering *out* of a Canvas** — should a Canvas emit cross-filters to a host v1 dashboard when embedded? Probably yes (it's already dataMask), but scope it explicitly.
+5. **Naming** — Canvas/CDL are placeholders. Decide before any public API/model name is minted (hard to change post-migration).
+6. **Prop-schema authoring** — hand-authored vs generated-from-TS. Start hand-authored for the 4-component slice; evaluate a generator before the manifest grows large.
diff --git a/superset-frontend/src/Canvas/EchartsViz.tsx b/superset-frontend/src/Canvas/EchartsViz.tsx
new file mode 100644
index 00000000000..c5aaf3fad0d
--- /dev/null
+++ b/superset-frontend/src/Canvas/EchartsViz.tsx
@@ -0,0 +1,244 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { CSSProperties, useEffect, useMemo, useRef } from 'react';
+import { useTheme } from '@apache-superset/core/theme';
+import {
+  init,
+  registerTheme,
+  use,
+  type EChartsCoreOption,
+  type EChartsType,
+} from 'echarts/core';
+import {
+  BarChart,
+  FunnelChart,
+  GaugeChart,
+  HeatmapChart,
+  LineChart,
+  PieChart,
+  RadarChart,
+  ScatterChart,
+  SunburstChart,
+  TreemapChart,
+} from 'echarts/charts';
+import {
+  DataZoomComponent,
+  GraphicComponent,
+  GridComponent,
+  LegendComponent,
+  MarkLineComponent,
+  PolarComponent,
+  RadarComponent,
+  TitleComponent,
+  TooltipComponent,
+  VisualMapComponent,
+} from 'echarts/components';
+import { CanvasRenderer } from 'echarts/renderers';
+import { t } from '@apache-superset/core/translation';
+import { VizNode } from './types';
+import { encodeToOption, resolveFormatters } from './resolve';
+import { useBoundQuery } from './runtime';
+
+use([
+  CanvasRenderer,
+  LineChart,
+  BarChart,
+  PieChart,
+  ScatterChart,
+  RadarChart,
+  FunnelChart,
+  GaugeChart,
+  TreemapChart,
+  SunburstChart,
+  HeatmapChart,
+  GridComponent,
+  TooltipComponent,
+  LegendComponent,
+  TitleComponent,
+  VisualMapComponent,
+  PolarComponent,
+  RadarComponent,
+  DataZoomComponent,
+  GraphicComponent,
+  MarkLineComponent,
+]);
+
+const THEME_NAME = 'supersetCanvas';
+
+type ThemeTokens = ReturnType<typeof useTheme>;
+
+/** Signature that changes when the app flips light/dark — drives re-theming. */
+const themeSignature = (theme: ThemeTokens): string =>
+  `${theme.colorText}|${theme.colorBgContainer}|${theme.colorPrimary}`;
+
+/**
+ * A full echarts theme built from antd tokens. Registered globally so axis,
+ * legend and title colours follow the app theme — the parts a per-chart
+ * `option` can't reach, since option values can't reference `@themeToken`s.
+ */
+function buildEchartsTheme(theme: ThemeTokens): Record<string, unknown> {
+  const text = theme.colorText;
+  const sub = theme.colorTextSecondary;
+  const border = theme.colorBorderSecondary ?? theme.colorBorder;
+  const split = theme.colorSplit ?? border;
+  const palette = [
+    theme.colorPrimary,
+    theme.colorSuccess,
+    theme.colorWarning,
+    theme.colorInfo,
+    theme.colorError,
+    theme.colorPrimaryBorder,
+    theme.colorWarningBorder,
+  ].filter(Boolean);
+  const axis = {
+    axisLine: { lineStyle: { color: border } },
+    axisTick: { lineStyle: { color: border } },
+    axisLabel: { color: sub },
+    splitLine: { lineStyle: { color: split } },
+    splitArea: { areaStyle: { color: ['transparent', 'transparent'] } },
+  };
+  return {
+    color: palette,
+    backgroundColor: 'transparent',
+    textStyle: { color: text, fontFamily: theme.fontFamily },
+    title: { textStyle: { color: text }, subtextStyle: { color: sub } },
+    legend: { textStyle: { color: sub } },
+    categoryAxis: axis,
+    valueAxis: axis,
+    logAxis: axis,
+    timeAxis: axis,
+    visualMap: { textStyle: { color: sub } },
+    graph: { color: palette },
+  };
+}
+
+/** Default framing; colours now come from the registered theme. */
+const themedBase: Record<string, unknown> = {
+  grid: { left: 56, right: 16, top: 32, bottom: 32, containLabel: true },
+};
+
+function EchartsChart({
+  option,
+  themeKey,
+  height = 320,
+  style,
+}: {
+  option: EChartsCoreOption;
+  themeKey: string;
+  height?: number;
+  style?: CSSProperties;
+}) {
+  const containerRef = useRef<HTMLDivElement>(null);
+  const chartRef = useRef<EChartsType>();
+  const optionRef = useRef(option);
+  optionRef.current = option;
+
+  // Re-init when the theme flips (an echarts instance binds its theme at init).
+  useEffect(() => {
+    const element = containerRef.current;
+    if (!element) {
+      return undefined;
+    }
+    const chart = init(element, THEME_NAME);
+    chartRef.current = chart;
+    chart.setOption(optionRef.current, { notMerge: true });
+    const handleResize = () => chart.resize();
+    const observer = new ResizeObserver(handleResize);
+    observer.observe(element);
+    window.addEventListener('resize', handleResize);
+    return () => {
+      observer.disconnect();
+      window.removeEventListener('resize', handleResize);
+      chart.dispose();
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [themeKey]);
+
+  useEffect(() => {
+    chartRef.current?.setOption(option, { notMerge: true });
+  }, [option]);
+
+  return (
+    <div
+      ref={containerRef}
+      data-test="canvas-echarts"
+      css={{ width: '100%', height }}
+      style={style}
+    />
+  );
+}
+
+/**
+ * The echarts head of the Viz union: fetch a governed query, map the result onto
+ * the declarative option via `encoding`, resolve formatters, render.
+ */
+export function EchartsViz({
+  node,
+  style,
+}: {
+  node: VizNode;
+  style?: CSSProperties;
+}) {
+  const theme = useTheme();
+  const { data } = node;
+  const { loading, error, result } = useBoundQuery(
+    data?.queryContext ?? { datasetId: -1, metrics: [] },
+  );
+
+  // Register (or refresh) the theme during render, before the child's init
+  // effect runs — child effects fire before parent effects, so an effect here
+  // would be too late for the first paint.
+  const themeKey = themeSignature(theme);
+  useMemo(() => {
+    registerTheme(THEME_NAME, buildEchartsTheme(theme));
+    return null;
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [themeKey]);
+
+  const option = useMemo<EChartsCoreOption | undefined>(() => {
+    if (!result || !data) {
+      return undefined;
+    }
+    const encoded = encodeToOption(
+      { ...themedBase, ...node.option },
+      data.encoding,
+      result,
+    );
+    return resolveFormatters(encoded) as EChartsCoreOption;
+  }, [result, data, node.option]);
+
+  if (loading) {
+    return <div data-test="canvas-echarts-loading">{t('Loading…')}</div>;
+  }
+  if (error) {
+    return (
+      <div data-test="canvas-echarts-error">{t('Query error: %s', error)}</div>
+    );
+  }
+  if (result && result.records.length === 0) {
+    return (
+      <div data-test="canvas-echarts-empty">{t('No data for this query')}</div>
+    );
+  }
+  if (!option) {
+    return null;
+  }
+  return <EchartsChart option={option} themeKey={themeKey} style={style} />;
+}
diff --git a/superset-frontend/src/Canvas/FilterControl.tsx b/superset-frontend/src/Canvas/FilterControl.tsx
new file mode 100644
index 00000000000..f94601d50bc
--- /dev/null
+++ b/superset-frontend/src/Canvas/FilterControl.tsx
@@ -0,0 +1,126 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { CSSProperties, useEffect, useState } from 'react';
+import { Select } from '@superset-ui/core/components';
+import { CdlFilter, Primitive } from './types';
+import { ActiveFilter, useFilters, useQueryRunner } from './runtime';
+
+interface Option {
+  value: string | number;
+  label: string;
+}
+
+export interface FilterControlProps {
+  filterId: string;
+  column: string;
+  datasetId?: number;
+  label?: string;
+  multiple?: boolean;
+  op?: CdlFilter['op'];
+  options?: Option[];
+  style?: CSSProperties;
+}
+
+/**
+ * A canvas filter: renders a (multi)select of a column's distinct values and
+ * writes the selection into the canvas-global filter store, which every bound
+ * chart on the same dataset merges into its query.
+ */
+export function FilterControl({
+  filterId,
+  column,
+  datasetId,
+  label,
+  multiple,
+  op,
+  options: provided,
+  style,
+}: FilterControlProps) {
+  const runner = useQueryRunner();
+  const { setFilter } = useFilters();
+  const [options, setOptions] = useState<Option[]>(provided ?? []);
+  const [value, setValue] = useState<Primitive | Primitive[] | undefined>(
+    multiple ? [] : undefined,
+  );
+
+  // Auto-populate options with the column's distinct values (COUNT(*) works on
+  // any dataset without needing a saved metric).
+  useEffect(() => {
+    if (provided?.length || datasetId == null || !column) {
+      return undefined;
+    }
+    let live = true;
+    runner
+      .run({ datasetId, metrics: ['COUNT(*)'], groupby: [column] })
+      .then(result => {
+        if (!live) return;
+        const seen = new Set<string>();
+        const opts: Option[] = [];
+        result.records.forEach(row => {
+          const raw = row[column];
+          const key = String(raw);
+          if (raw != null && !seen.has(key)) {
+            seen.add(key);
+            opts.push({ value: raw as string | number, label: key });
+          }
+        });
+        setOptions(opts);
+      })
+      .catch(() => undefined);
+    return () => {
+      live = false;
+    };
+  }, [runner, datasetId, column, provided]);
+
+  const handleChange = (next: unknown) => {
+    const selected = next as Primitive | Primitive[] | undefined;
+    setValue(selected);
+    const isEmpty =
+      selected == null ||
+      selected === '' ||
+      (Array.isArray(selected) && selected.length === 0);
+    const active: ActiveFilter | null = isEmpty
+      ? null
+      : {
+          datasetId,
+          filter: {
+            col: column,
+            op: op ?? (multiple ? 'IN' : '=='),
+            val: selected as CdlFilter['val'],
+          },
+        };
+    setFilter(filterId, active);
+  };
+
+  return (
+    <div style={style}>
+      <Select
+        ariaLabel={label ?? column}
+        header={label ?? column}
+        placeholder={label ?? column}
+        mode={multiple ? 'multiple' : undefined}
+        options={options}
+        value={value as string | number | (string | number)[] | undefined}
+        onChange={handleChange}
+        allowClear
+      />
+    </div>
+  );
+}
diff --git a/superset-frontend/src/Canvas/SupersetChartViz.tsx b/superset-frontend/src/Canvas/SupersetChartViz.tsx
new file mode 100644
index 00000000000..602d86b2cc2
--- /dev/null
+++ b/superset-frontend/src/Canvas/SupersetChartViz.tsx
@@ -0,0 +1,204 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { CSSProperties, useEffect, useMemo, useState } from 'react';
+import {
+  getClientErrorObject,
+  SupersetClient,
+  SuperChart,
+  type QueryData,
+} from '@superset-ui/core';
+import { getChartDataRequest } from 'src/components/Chart/chartAction';
+import { t } from '@apache-superset/core/translation';
+import { CdlFilter, VizNode } from './types';
+import { resolveVars } from './resolve';
+import { useActiveFilters, useUiState, useVariables } from './runtime';
+
+interface ChartMeta {
+  vizType: string;
+  formData: Record<string, unknown>;
+  datasourceId?: number;
+}
+
+const parseJson = (value: unknown): Record<string, unknown> | undefined => {
+  if (typeof value === 'string' && value.trim()) {
+    try {
+      return JSON.parse(value) as Record<string, unknown>;
+    } catch {
+      return undefined;
+    }
+  }
+  return (value as Record<string, unknown>) || undefined;
+};
+
+/** SupersetClient rejects with a Response, not an Error — unwrap it. */
+const describeError = async (err: unknown): Promise<string> => {
+  try {
+    const obj = await getClientErrorObject(
+      err as Parameters<typeof getClientErrorObject>[0],
+    );
+    return obj.error || obj.message || JSON.stringify(obj).slice(0, 200);
+  } catch {
+    return err instanceof Error ? err.message : String(err);
+  }
+};
+
+/**
+ * The governed head of the Viz union: renders an existing saved Superset chart
+ * with its own viz plugin and form_data. Data goes through the same
+ * getChartDataRequest the dashboard uses, so the plugin's buildQuery (and the
+ * legacy API fallback) apply. Canvas filters are passed as `extra_form_data`,
+ * exactly how native dashboard filters reach a chart.
+ */
+export function SupersetChartViz({
+  node,
+  style,
+}: {
+  node: VizNode;
+  style?: CSSProperties;
+}) {
+  const { chartId } = node;
+  const { vars } = useVariables();
+  const { refreshNonce } = useUiState();
+  const [meta, setMeta] = useState<ChartMeta | undefined>();
+  const [queriesData, setQueriesData] = useState<QueryData[] | undefined>();
+  const [error, setError] = useState<string | undefined>();
+
+  // 1. Load the saved chart's viz type and form_data.
+  useEffect(() => {
+    if (!chartId) {
+      return undefined;
+    }
+    let live = true;
+    setError(undefined);
+    SupersetClient.get({ endpoint: `/api/v1/chart/${chartId}` })
+      .then(({ json }) => {
+        if (!live) return;
+        const { result } = json as unknown as {
+          result: Record<string, unknown>;
+        };
+        // Prefer the API's form_data (already normalised); fall back to params.
+        const base =
+          parseJson(result.form_data) ?? parseJson(result.params) ?? {};
+        const vizType = String(result.viz_type ?? base.viz_type ?? '');
+        // A saved chart's params.datasource can be stale (example charts ship
+        // with the wrong id). Slice.form_data overrides it server-side with the
+        // authoritative datasource_id, so do the same here.
+        const datasourceId = result.datasource_id;
+        const datasourceType = String(result.datasource_type ?? 'table');
+        const datasource =
+          datasourceId != null
+            ? `${datasourceId}__${datasourceType}`
+            : base.datasource;
+        setMeta({
+          vizType,
+          formData: {
+            ...base,
+            datasource,
+            viz_type: vizType,
+            slice_id: chartId,
+          },
+          datasourceId: Number(datasourceId ?? NaN),
+        });
+      })
+      .catch(async err => {
+        const message = await describeError(err);
+        if (live) setError(message);
+      });
+    return () => {
+      live = false;
+    };
+  }, [chartId]);
+
+  // Canvas-global filters for this chart's dataset, plus node-level ones.
+  const activeFilters = useActiveFilters(meta?.datasourceId ?? -1);
+  const extraFilters = useMemo<CdlFilter[]>(
+    () => [...activeFilters, ...resolveVars(node.filters ?? [], vars)],
+    [activeFilters, node.filters, vars],
+  );
+  const filterKey = JSON.stringify(extraFilters);
+
+  // 2. Fetch data through the standard chart-data path.
+  useEffect(() => {
+    if (!meta) {
+      return undefined;
+    }
+    let live = true;
+    const formData = extraFilters.length
+      ? {
+          ...meta.formData,
+          extra_form_data: {
+            ...((meta.formData.extra_form_data as Record<string, unknown>) ??
+              {}),
+            filters: extraFilters.map(f => ({
+              col: f.col,
+              op: f.op,
+              val: f.val,
+            })),
+          },
+        }
+      : meta.formData;
+
+    getChartDataRequest({ formData, resultFormat: 'json', resultType: 'full' })
+      .then(({ json }) => {
+        if (live) setQueriesData(json.result);
+      })
+      .catch(async err => {
+        const message = await describeError(err);
+        if (live) setError(message);
+      });
+    return () => {
+      live = false;
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [meta, filterKey, refreshNonce]);
+
+  if (!chartId) {
+    return (
+      <div data-test="canvas-superset-chart-error">
+        {t('chartId is required')}
+      </div>
+    );
+  }
+  if (error) {
+    return (
+      <div data-test="canvas-superset-chart-error">
+        {t('Chart error: %s', error)}
+      </div>
+    );
+  }
+  if (!meta || !queriesData) {
+    return <div data-test="canvas-superset-chart-loading">{t('Loading…')}</div>;
+  }
+
+  return (
+    <div
+      data-test="canvas-superset-chart"
+      style={{ width: '100%', height: 320, ...style }}
+    >
+      <SuperChart
+        chartType={meta.vizType}
+        formData={meta.formData}
+        queriesData={queriesData}
+        width="100%"
+        height="100%"
+      />
+    </div>
+  );
+}
diff --git a/superset-frontend/src/Canvas/actions.ts b/superset-frontend/src/Canvas/actions.ts
new file mode 100644
index 00000000000..be0b1fde5de
--- /dev/null
+++ b/superset-frontend/src/Canvas/actions.ts
@@ -0,0 +1,113 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+  CdlAction,
+  CdlFilter,
+  Primitive,
+  VariableScope,
+  VariableValues,
+} from './types';
+import { isVarRef } from './resolve';
+
+/** Token that resolves to the value emitted by the triggering event. */
+const EVENT_TOKEN = '$event';
+const HTTP_URL = /^https?:\/\//i;
+
+/**
+ * The side-effecting surface an action can touch. Every capability is an
+ * explicit method here; there is no escape to arbitrary code. In-app, filter
+ * methods dispatch onto dataMask; in the prototype they are injected.
+ */
+export interface ActionContext {
+  vars: VariableValues;
+  setVariable: (name: string, value: Primitive) => void;
+  applyFilter: (filter: Pick<CdlFilter, 'col' | 'op' | 'val'>) => void;
+  crossFilter: (filter: Pick<CdlFilter, 'col' | 'op' | 'val'>) => void;
+  clearFilters: (scope?: VariableScope) => void;
+  navigateTab: (tabsId: string, tab: string) => void;
+  setModalOpen: (modalId: string, open: boolean) => void;
+  refresh: (target?: string) => void;
+  /** Value from the event that triggered this action list (e.g. Select value). */
+  eventValue?: Primitive;
+}
+
+function resolveActionValue(value: unknown, ctx: ActionContext): Primitive {
+  if (value === EVENT_TOKEN) {
+    return ctx.eventValue as Primitive;
+  }
+  if (isVarRef(value)) {
+    return ctx.vars[value.slice(1)];
+  }
+  return value as Primitive;
+}
+
+/** Run an ordered list of declarative actions against the injected context. */
+export function runActions(
+  actions: CdlAction[] | undefined,
+  ctx: ActionContext,
+): void {
+  (actions ?? []).forEach(action => {
+    switch (action.action) {
+      case 'setVariable':
+        ctx.setVariable(action.name, resolveActionValue(action.value, ctx));
+        break;
+      case 'applyFilter':
+        ctx.applyFilter({
+          col: action.col,
+          op: action.op,
+          val: resolveActionValue(action.val, ctx),
+        });
+        break;
+      case 'crossFilter':
+        ctx.crossFilter({
+          col: action.col,
+          op: action.op,
+          val: resolveActionValue(action.val, ctx),
+        });
+        break;
+      case 'clearFilters':
+        ctx.clearFilters(action.scope);
+        break;
+      case 'navigateTab':
+        ctx.navigateTab(action.tabsId, action.tab);
+        break;
+      case 'openModal':
+        ctx.setModalOpen(action.modalId, true);
+        break;
+      case 'closeModal':
+        ctx.setModalOpen(action.modalId, false);
+        break;
+      case 'openUrl':
+        if (HTTP_URL.test(action.url)) {
+          if (action.newTab) {
+            window.open(action.url, '_blank', 'noopener,noreferrer');
+          } else {
+            window.location.assign(action.url);
+          }
+        }
+        break;
+      case 'refresh':
+        ctx.refresh(action.target);
+        break;
+      default:
+        break;
+    }
+  });
+}
diff --git a/superset-frontend/src/Canvas/catalog.ts b/superset-frontend/src/Canvas/catalog.ts
new file mode 100644
index 00000000000..8cffc0409ce
--- /dev/null
+++ b/superset-frontend/src/Canvas/catalog.ts
@@ -0,0 +1,191 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * The catalog is the schema half of the component manifest — pure metadata, no
+ * React. It is the allowlist of node types plus their contract (events,
+ * bindable props). The renderer's manifest maps these names to components; the
+ * validator and (eventually) the backend/MCP JSON-schema consume the same
+ * source so the AI-facing contract never drifts from the renderer.
+ */
+
+export type NodeCategory = 'layout' | 'viz' | 'control' | 'display';
+
+export interface CatalogEntry {
+  name: string;
+  category: NodeCategory;
+  /** May hold `children`. */
+  container: boolean;
+  /** Allowed `on.*` event names. */
+  events: string[];
+  /** Props that accept a `$var` reference / two-way `bind`. */
+  bindableProps: string[];
+  /** Props that must be present. */
+  requiredProps: string[];
+}
+
+export const NODE_CATALOG: Record<string, CatalogEntry> = {
+  Column: {
+    name: 'Column',
+    category: 'layout',
+    container: true,
+    events: [],
+    bindableProps: [],
+    requiredProps: [],
+  },
+  Row: {
+    name: 'Row',
+    category: 'layout',
+    container: true,
+    events: [],
+    bindableProps: [],
+    requiredProps: [],
+  },
+  Card: {
+    name: 'Card',
+    category: 'layout',
+    container: true,
+    events: [],
+    bindableProps: [],
+    requiredProps: [],
+  },
+  Tabs: {
+    name: 'Tabs',
+    category: 'layout',
+    container: true,
+    events: [],
+    bindableProps: [],
+    requiredProps: [],
+  },
+  Board: {
+    name: 'Board',
+    category: 'layout',
+    container: true,
+    events: [],
+    bindableProps: [],
+    requiredProps: [],
+  },
+  Tab: {
+    name: 'Tab',
+    category: 'layout',
+    container: true,
+    events: [],
+    bindableProps: [],
+    requiredProps: ['label'],
+  },
+  Divider: {
+    name: 'Divider',
+    category: 'display',
+    container: false,
+    events: [],
+    bindableProps: [],
+    requiredProps: [],
+  },
+  Alert: {
+    name: 'Alert',
+    category: 'display',
+    container: false,
+    events: [],
+    bindableProps: [],
+    requiredProps: ['message'],
+  },
+  Progress: {
+    name: 'Progress',
+    category: 'display',
+    container: false,
+    events: [],
+    bindableProps: ['value'],
+    requiredProps: [],
+  },
+  Collapse: {
+    name: 'Collapse',
+    category: 'layout',
+    container: true,
+    events: [],
+    bindableProps: [],
+    requiredProps: [],
+  },
+  Modal: {
+    name: 'Modal',
+    category: 'layout',
+    container: true,
+    events: [],
+    bindableProps: [],
+    requiredProps: [],
+  },
+  Input: {
+    name: 'Input',
+    category: 'control',
+    container: false,
+    events: ['change'],
+    bindableProps: ['value'],
+    requiredProps: [],
+  },
+  Switch: {
+    name: 'Switch',
+    category: 'control',
+    container: false,
+    events: ['change'],
+    bindableProps: ['value'],
+    requiredProps: [],
+  },
+  Select: {
+    name: 'Select',
+    category: 'control',
+    container: false,
+    events: ['change'],
+    bindableProps: ['value'],
+    requiredProps: ['options'],
+  },
+  Button: {
+    name: 'Button',
+    category: 'control',
+    container: false,
+    events: ['click'],
+    bindableProps: [],
+    requiredProps: [],
+  },
+  Filter: {
+    name: 'Filter',
+    category: 'control',
+    container: false,
+    events: [],
+    bindableProps: [],
+    requiredProps: ['column'],
+  },
+  Markdown: {
+    name: 'Markdown',
+    category: 'display',
+    container: false,
+    events: [],
+    bindableProps: [],
+    requiredProps: ['text'],
+  },
+  Viz: {
+    name: 'Viz',
+    category: 'viz',
+    container: false,
+    events: [],
+    bindableProps: [],
+    requiredProps: [],
+  },
+};
+
+export const isKnownType = (type: string): boolean =>
+  Object.prototype.hasOwnProperty.call(NODE_CATALOG, type);
diff --git a/superset-frontend/src/Canvas/cdl.test.ts b/superset-frontend/src/Canvas/cdl.test.ts
new file mode 100644
index 00000000000..0e4adba0914
--- /dev/null
+++ b/superset-frontend/src/Canvas/cdl.test.ts
@@ -0,0 +1,306 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { validateCanvas } from './validator';
+import { encodeToOption, resolveVars, resolveFormatters } from './resolve';
+import { runActions, ActionContext } from './actions';
+import { salesCanvas } from './fixtures/salesCanvas';
+import { CanvasDefinition } from './types';
+
+const clone = (): CanvasDefinition => JSON.parse(JSON.stringify(salesCanvas));
+
+test('the demo canvas is valid', () => {
+  expect(validateCanvas(salesCanvas)).toEqual({ valid: true, errors: [] });
+});
+
+test('rejects an executable string in an echarts option (no-code invariant)', () => {
+  const def = clone();
+  const chart = def.tree.children![2] as { option: Record<string, unknown> };
+  chart.option.tooltip = { formatter: '(v) => v.toFixed(2)' };
+  const result = validateCanvas(def);
+  expect(result.valid).toBe(false);
+  expect(
+    result.errors.some(e =>
+      /formatter must be a declarative object/.test(e.message),
+    ),
+  ).toBe(true);
+});
+
+test('rejects a reference to an undeclared variable', () => {
+  const def = clone();
+  const select = def.tree.children![1].children![0] as {
+    bind: Record<string, string>;
+  };
+  select.bind.value = '$undeclared';
+  const result = validateCanvas(def);
+  expect(result.valid).toBe(false);
+  expect(result.errors.some(e => /undeclared variable/.test(e.message))).toBe(
+    true,
+  );
+});
+
+test('rejects a javascript: url in openUrl', () => {
+  const def = clone();
+  def.tree.children![1].children![1].on = {
+    // eslint-disable-next-line no-script-url
+    click: [{ action: 'openUrl', url: 'javascript:alert(1)' }],
+  };
+  const result = validateCanvas(def);
+  expect(result.valid).toBe(false);
+  expect(result.errors.some(e => /http\(s\) only/.test(e.message))).toBe(true);
+});
+
+test('rejects children on a non-container node', () => {
+  const def = clone();
+  (def.tree.children![0] as { children: unknown[] }).children = [
+    { id: 'x', type: 'Markdown', props: { text: 'nope' } },
+  ];
+  const result = validateCanvas(def);
+  expect(result.valid).toBe(false);
+  expect(result.errors.some(e => /cannot have children/.test(e.message))).toBe(
+    true,
+  );
+});
+
+test('accepts the Alert/Progress/Collapse/Modal nodes and modal actions', () => {
+  const definition: CanvasDefinition = {
+    cdlVersion: 2,
+    variables: { goal: { type: 'number', default: 60, scope: 'ui' } },
+    tree: {
+      id: 'root',
+      type: 'Column',
+      children: [
+        {
+          id: 'a',
+          type: 'Alert',
+          props: { message: 'Partial year', type: 'warning' },
+        },
+        { id: 'p', type: 'Progress', bind: { value: '$goal' } },
+        {
+          id: 'c',
+          type: 'Collapse',
+          children: [
+            {
+              id: 'sec',
+              type: 'Column',
+              props: { label: 'Notes' },
+              children: [{ id: 'm', type: 'Markdown', props: { text: 'hi' } }],
+            },
+          ],
+        },
+        {
+          id: 'btn',
+          type: 'Button',
+          props: { label: 'Details' },
+          on: { click: [{ action: 'openModal', modalId: 'dlg' }] },
+        },
+        {
+          id: 'dlg',
+          type: 'Modal',
+          props: { title: 'Detail' },
+          children: [{ id: 'm2', type: 'Markdown', props: { text: 'drill' } }],
+        },
+      ],
+    },
+  };
+  expect(validateCanvas(definition)).toEqual({ valid: true, errors: [] });
+});
+
+test('openModal without a modalId is rejected', () => {
+  const definition = {
+    cdlVersion: 2,
+    variables: {},
+    tree: {
+      id: 'b',
+      type: 'Button',
+      on: { click: [{ action: 'openModal' }] },
+    },
+  } as unknown as CanvasDefinition;
+  const result = validateCanvas(definition);
+  expect(result.valid).toBe(false);
+  expect(result.errors.some(e => /missing "modalId"/.test(e.message))).toBe(
+    true,
+  );
+});
+
+test('accepts a Board with layout placement and freeform styles', () => {
+  const definition: CanvasDefinition = {
+    cdlVersion: 2,
+    variables: {},
+    tree: {
+      id: 'board',
+      type: 'Board',
+      props: { columns: 12, rowHeight: 40 },
+      children: [
+        {
+          id: 'bg',
+          type: 'Markdown',
+          props: { text: 'behind' },
+          layout: { x: 0, y: 0, w: 8, h: 4 },
+        },
+        {
+          id: 'front',
+          type: 'Markdown',
+          props: { text: 'over it, tilted' },
+          layout: { x: 5, y: 1, w: 5, h: 3, z: 2 },
+          style: { position: 'relative', transform: 'rotate(-4deg)' },
+        },
+      ],
+    },
+  };
+  expect(validateCanvas(definition)).toEqual({ valid: true, errors: [] });
+});
+
+test('rejects a malformed Board layout', () => {
+  const definition = {
+    cdlVersion: 2,
+    variables: {},
+    tree: {
+      id: 'board',
+      type: 'Board',
+      children: [
+        {
+          id: 'x',
+          type: 'Markdown',
+          props: { text: 'a' },
+          layout: { x: 0, y: 0, w: 0, h: 'tall' },
+        },
+      ],
+    },
+  } as unknown as CanvasDefinition;
+  const result = validateCanvas(definition);
+  expect(result.valid).toBe(false);
+  expect(result.errors.some(e => /w must be at least 1/.test(e.message))).toBe(
+    true,
+  );
+  expect(result.errors.some(e => /h must be a number/.test(e.message))).toBe(
+    true,
+  );
+});
+
+test('resolveVars substitutes $var references deeply', () => {
+  const resolved = resolveVars(
+    { filters: [{ col: 'region', val: '$region' }], keep: 1 },
+    { region: 'EMEA' },
+  );
+  expect(resolved).toEqual({
+    filters: [{ col: 'region', val: 'EMEA' }],
+    keep: 1,
+  });
+});
+
+test('encodeToOption maps query results onto series data', () => {
+  const option = encodeToOption(
+    { series: [{ type: 'bar' }] },
+    { x: 'month', y: 'sales' },
+    {
+      columns: ['month', 'sales'],
+      records: [
+        { month: 'Jan', sales: 10 },
+        { month: 'Feb', sales: 20 },
+      ],
+    },
+  );
+  expect(option.xAxis).toEqual({ type: 'category', data: ['Jan', 'Feb'] });
+  expect(option.series).toEqual([
+    { type: 'bar', name: 'sales', data: [10, 20] },
+  ]);
+});
+
+test('encodeToOption shapes pie data as name/value and drops axes', () => {
+  const option = encodeToOption(
+    { series: [{ type: 'pie' }] },
+    { x: 'genre', y: 'sales' },
+    {
+      columns: ['genre', 'sales'],
+      records: [
+        { genre: 'Action', sales: 5 },
+        { genre: 'Sports', sales: 7 },
+      ],
+    },
+  );
+  expect(option.series).toEqual([
+    {
+      type: 'pie',
+      name: 'sales',
+      data: [
+        { name: 'Action', value: 5 },
+        { name: 'Sports', value: 7 },
+      ],
+    },
+  ]);
+  expect(option.xAxis).toBeUndefined();
+});
+
+test('encodeToOption shapes scatter data as [x, y, label] pairs', () => {
+  const option = encodeToOption(
+    { series: [{ type: 'scatter' }] },
+    { x: 'publisher', y: ['na', 'eu'] },
+    {
+      columns: ['publisher', 'na', 'eu'],
+      records: [{ publisher: 'Nintendo', na: 1, eu: 2 }],
+    },
+  );
+  const series = option.series as Array<{ data: unknown[] }>;
+  expect(series[0].data).toEqual([[1, 2, 'Nintendo']]);
+});
+
+test('encodeToOption builds radar indicators from categories', () => {
+  const option = encodeToOption(
+    { series: [{ type: 'radar' }] },
+    { x: 'genre', y: 'sales' },
+    {
+      columns: ['genre', 'sales'],
+      records: [
+        { genre: 'Action', sales: 5 },
+        { genre: 'Sports', sales: 9 },
+      ],
+    },
+  );
+  const radar = option.radar as { indicator: Array<{ name: string }> };
+  expect(radar.indicator.map(i => i.name)).toEqual(['Action', 'Sports']);
+  const series = option.series as Array<{ data: Array<{ value: number[] }> }>;
+  expect(series[0].data[0].value).toEqual([5, 9]);
+});
+
+test('resolveFormatters turns a declarative spec into a function', () => {
+  const resolved = resolveFormatters({
+    tooltip: { valueFormatter: { kind: 'currency', currency: 'USD' } },
+  }) as { tooltip: { valueFormatter: (v: unknown) => string } };
+  const fn = resolved.tooltip.valueFormatter;
+  expect(typeof fn).toBe('function');
+  expect(fn(1000)).toMatch(/\$1,000/);
+});
+
+test('runActions dispatches setVariable with the event value', () => {
+  const writes: Array<[string, unknown]> = [];
+  const ctx: ActionContext = {
+    vars: {},
+    setVariable: (name, value) => writes.push([name, value]),
+    applyFilter: () => {},
+    crossFilter: () => {},
+    clearFilters: () => {},
+    navigateTab: () => {},
+    setModalOpen: () => {},
+    refresh: () => {},
+    eventValue: 'EMEA',
+  };
+  runActions([{ action: 'setVariable', name: 'region', value: '$event' }], ctx);
+  expect(writes).toEqual([['region', 'EMEA']]);
+});
diff --git a/superset-frontend/src/Canvas/fixtures/salesCanvas.ts b/superset-frontend/src/Canvas/fixtures/salesCanvas.ts
new file mode 100644
index 00000000000..48cef3cf052
--- /dev/null
+++ b/superset-frontend/src/Canvas/fixtures/salesCanvas.ts
@@ -0,0 +1,125 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { CanvasDefinition, CdlQueryContext } from '../types';
+import { QueryResult } from '../resolve';
+import { QueryRunner } from '../runtime';
+
+/**
+ * Demo canvas: a Region select two-way bound to `$region`, a Reset button, and
+ * a bound echarts line chart whose query filters on `$region`. Changing the
+ * select re-runs the query — the reactive loop the prototype proves.
+ */
+export const salesCanvas: CanvasDefinition = {
+  cdlVersion: 2,
+  variables: {
+    region: { type: 'string', default: 'APAC', scope: 'query' },
+  },
+  tree: {
+    id: 'root',
+    type: 'Column',
+    children: [
+      {
+        id: 'title',
+        type: 'Markdown',
+        props: { text: 'Monthly sales by region' },
+      },
+      {
+        id: 'controls',
+        type: 'Row',
+        children: [
+          {
+            id: 'region-select',
+            type: 'Select',
+            props: {
+              label: 'Region',
+              options: [
+                { value: 'APAC', label: 'APAC' },
+                { value: 'EMEA', label: 'EMEA' },
+                { value: 'AMER', label: 'AMER' },
+              ],
+            },
+            bind: { value: '$region' },
+          },
+          {
+            id: 'reset',
+            type: 'Button',
+            props: { children: 'Reset', buttonStyle: 'secondary' },
+            on: {
+              click: [{ action: 'setVariable', name: 'region', value: 'APAC' }],
+            },
+          },
+        ],
+      },
+      {
+        id: 'chart',
+        type: 'Viz',
+        renderer: 'echarts',
+        data: {
+          queryContext: {
+            datasetId: 1,
+            metrics: ['SUM(sales)'],
+            groupby: ['month'],
+            filters: [{ col: 'region', op: '==', val: '$region' }],
+          },
+          encoding: { x: 'month', y: 'SUM(sales)' },
+        },
+        option: {
+          series: [{ type: 'line', smooth: true }],
+          tooltip: {
+            trigger: 'axis',
+            valueFormatter: { kind: 'currency', currency: 'USD' },
+          },
+          yAxis: {
+            type: 'value',
+            axisLabel: { formatter: { kind: 'currency', currency: 'USD' } },
+          },
+        },
+      },
+    ],
+  },
+};
+
+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
+const SERIES_BY_REGION: Record<string, number[]> = {
+  APAC: [120, 132, 101, 134, 90, 230],
+  EMEA: [220, 182, 191, 234, 290, 330],
+  AMER: [150, 232, 201, 154, 190, 330],
+};
+
+/** In-memory QueryRunner standing in for /api/v1/chart/data during the prototype. */
+export function createMockRunner(delayMs = 0): QueryRunner {
+  return {
+    run: (queryContext: CdlQueryContext): Promise<QueryResult> => {
+      const regionFilter = queryContext.filters?.find(f => f.col === 'region');
+      const region = String(regionFilter?.val ?? 'APAC');
+      const values = SERIES_BY_REGION[region] ?? SERIES_BY_REGION.APAC;
+      const records = MONTHS.map((month, i) => ({
+        month,
+        'SUM(sales)': values[i] * 1000,
+      }));
+      return new Promise(resolve => {
+        setTimeout(
+          () => resolve({ columns: ['month', 'SUM(sales)'], records }),
+          delayMs,
+        );
+      });
+    },
+  };
+}
diff --git a/superset-frontend/src/Canvas/index.ts b/superset-frontend/src/Canvas/index.ts
new file mode 100644
index 00000000000..0c63f5d9b15
--- /dev/null
+++ b/superset-frontend/src/Canvas/index.ts
@@ -0,0 +1,28 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export { CanvasRenderer } from './CanvasRenderer';
+export type { CanvasRendererProps } from './CanvasRenderer';
+export { validateCanvas } from './validator';
+export type { ValidationError, ValidationResult } from './validator';
+export { MANIFEST } from './manifest';
+export { NODE_CATALOG } from './catalog';
+export type { QueryRunner } from './runtime';
+export { createSupersetQueryRunner } from './queryRunner';
+export * from './types';
diff --git a/superset-frontend/src/Canvas/manifest.tsx b/superset-frontend/src/Canvas/manifest.tsx
new file mode 100644
index 00000000000..c2854616de4
--- /dev/null
+++ b/superset-frontend/src/Canvas/manifest.tsx
@@ -0,0 +1,468 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { CSSProperties, FC, ReactNode } from 'react';
+import { styled } from '@apache-superset/core/theme';
+import {
+  Button,
+  Card,
+  Collapse,
+  Divider,
+  Input,
+  Modal,
+  Progress,
+  SafeMarkdown,
+  Select,
+  Switch,
+  Tabs,
+} from '@superset-ui/core/components';
+import { Alert } from '@apache-superset/core/components';
+import { CatalogEntry, NODE_CATALOG } from './catalog';
+import { CdlFilter, CdlNode, Primitive, isVizNode } from './types';
+import { EchartsViz } from './EchartsViz';
+import { FilterControl } from './FilterControl';
+import { SupersetChartViz } from './SupersetChartViz';
+import { useUiState } from './runtime';
+
+/**
+ * The manifest maps a catalog type to a React adapter. Adapters are thin: the
+ * renderer resolves props/bindings/events generically and hands them here; each
+ * adapter only knows how to wire the normalized inputs onto its component.
+ */
+export interface AdapterProps {
+  node: CdlNode;
+  resolvedProps: Record<string, unknown>;
+  children?: ReactNode;
+  /** Run the node's declarative handler list for an event. */
+  fire: (event: string, value?: Primitive) => void;
+  /** Current value of a two-way-bound prop (from the variable store). */
+  getBound: (prop: string) => Primitive | undefined;
+  /** Write a two-way-bound prop back to the variable store. */
+  setBound: (prop: string, value: Primitive) => void;
+  /**
+   * Render a specific child node. Containers that need structural control over
+   * their children (e.g. Tabs) use this instead of the flat `children`.
+   */
+  renderNode: (child: CdlNode) => ReactNode;
+  /** Resolved inline styling from the node's `style` (theme tokens applied). */
+  style?: CSSProperties;
+}
+
+export interface ManifestEntry {
+  component: FC<AdapterProps>;
+  catalog: CatalogEntry;
+}
+
+const Column = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: ${({ theme }) => theme.sizeUnit * 3}px;
+  width: 100%;
+  /* padding must not widen the box (no global reset in this codebase) */
+  box-sizing: border-box;
+  min-width: 0;
+`;
+
+const Row = styled.div`
+  display: flex;
+  flex-direction: row;
+  gap: ${({ theme }) => theme.sizeUnit * 3}px;
+  width: 100%;
+  box-sizing: border-box;
+  min-width: 0;
+  /* Wrap rather than overflow on narrow viewports. */
+  flex-wrap: wrap;
+  & > * {
+    flex: 1 1 0;
+    min-width: 0;
+  }
+`;
+
+const ColumnAdapter: FC<AdapterProps> = ({ children, style }) => (
+  <Column style={style}>{children}</Column>
+);
+
+const RowAdapter: FC<AdapterProps> = ({ children, style }) => (
+  <Row style={style}>{children}</Row>
+);
+
+interface Option {
+  value: string | number;
+  label: string;
+}
+
+const SelectAdapter: FC<AdapterProps> = ({
+  resolvedProps,
+  fire,
+  getBound,
+  setBound,
+  style,
+}) => {
+  const bound = getBound('value');
+  const value = bound ?? (resolvedProps.value as Primitive | undefined);
+  const options = (resolvedProps.options as Option[]) ?? [];
+  return (
+    <div style={style}>
+      <Select
+        ariaLabel={(resolvedProps.label as string) ?? 'Select'}
+        header={resolvedProps.label as ReactNode}
+        options={options}
+        value={(value ?? null) as string | number | null}
+        onChange={(next: unknown) => {
+          const val = next as Primitive;
+          setBound('value', val);
+          fire('change', val);
+        }}
+      />
+    </div>
+  );
+};
+
+// Agents label controls under varying keys — accept the common ones.
+const firstText = (
+  props: Record<string, unknown>,
+  keys: string[],
+): ReactNode => {
+  const key = keys.find(k => props[k] != null);
+  return key ? (props[key] as ReactNode) : undefined;
+};
+
+const ButtonAdapter: FC<AdapterProps> = ({ resolvedProps, fire, style }) => (
+  <Button
+    style={style}
+    buttonStyle={
+      (resolvedProps.buttonStyle as 'primary' | 'secondary') ?? 'secondary'
+    }
+    buttonSize="small"
+    onClick={() => fire('click')}
+  >
+    {firstText(resolvedProps, [
+      'children',
+      'label',
+      'text',
+      'title',
+      'content',
+    ]) ?? 'Button'}
+  </Button>
+);
+
+const MarkdownAdapter: FC<AdapterProps> = ({ resolvedProps, style }) => {
+  const source = firstText(resolvedProps, [
+    'text',
+    'source',
+    'content',
+    'markdown',
+  ]);
+  return (
+    <div
+      data-test="canvas-markdown"
+      // SafeMarkdown wraps text in <p>/<h*> with default outer margins, which
+      // inflates a node's height — in a fixed-height Board that overflows into
+      // the cell below. Collapse the leading/trailing margins; inter-paragraph
+      // spacing (for narrative docs) is preserved.
+      css={{
+        '& > :first-child': { marginTop: 0 },
+        '& > :last-child': { marginBottom: 0 },
+      }}
+      style={style}
+    >
+      <SafeMarkdown source={source == null ? '' : String(source)} />
+    </div>
+  );
+};
+
+const InlineControl = styled.div`
+  display: flex;
+  align-items: center;
+  gap: ${({ theme }) => theme.sizeUnit * 2}px;
+`;
+
+const CardAdapter: FC<AdapterProps> = ({ resolvedProps, children, style }) => (
+  <Card
+    title={firstText(resolvedProps, ['title', 'label', 'header'])}
+    padded
+    style={style}
+  >
+    {children}
+  </Card>
+);
+
+const DividerAdapter: FC<AdapterProps> = ({ style }) => (
+  <Divider style={style} />
+);
+
+/** Tabs owns its children structurally, so it renders them via `renderNode`. */
+const TabsAdapter: FC<AdapterProps> = ({
+  node,
+  resolvedProps,
+  renderNode,
+  style,
+}) => {
+  const { activeTabs, setActiveTab } = useUiState();
+  const tabNodes = (node.children ?? []).filter(child => child.type === 'Tab');
+  const defaultKey = (resolvedProps.defaultTab as string) ?? tabNodes[0]?.id;
+  const activeKey = activeTabs[node.id] ?? defaultKey;
+  return (
+    <Tabs
+      style={style}
+      activeKey={activeKey}
+      onChange={key => setActiveTab(node.id, key)}
+      items={tabNodes.map(tab => ({
+        key: tab.id,
+        label: String(
+          firstText(tab.props ?? {}, ['label', 'title', 'text']) ?? tab.id,
+        ),
+        children: renderNode(tab),
+      }))}
+    />
+  );
+};
+
+const TabAdapter: FC<AdapterProps> = ({ children, style }) => (
+  <Column style={style}>{children}</Column>
+);
+
+/**
+ * Freeform layout: children are placed by their `layout` {x,y,w,h,z} on a
+ * `columns`-wide grid of `rowHeight`px rows. Overlap is allowed (grid areas can
+ * share cells), which is why `z` exists. Responsive — columns are fractions.
+ */
+const BoardAdapter: FC<AdapterProps> = ({
+  node,
+  resolvedProps,
+  renderNode,
+  style,
+}) => {
+  const columns = Number(resolvedProps.columns ?? 12);
+  const rowHeight = Number(resolvedProps.rowHeight ?? 40);
+  const gap = Number(resolvedProps.gap ?? 8);
+  return (
+    <div
+      style={{
+        display: 'grid',
+        gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
+        // rowHeight is a MINIMUM, not a cap: rows grow to fit content so an
+        // over-tall cell pushes the layout down instead of overlapping.
+        gridAutoRows: `minmax(${rowHeight}px, auto)`,
+        gap,
+        width: '100%',
+        ...style,
+      }}
+    >
+      {(node.children ?? []).map(child => {
+        const l = child.layout;
+        // display:grid makes the single child stretch to fill the cell in both
+        // dimensions, so a Viz with height:100% fills its board cell.
+        const cell: CSSProperties = l
+          ? {
+              gridColumn: `${l.x + 1} / span ${l.w}`,
+              gridRow: `${l.y + 1} / span ${l.h}`,
+              zIndex: l.z,
+              display: 'grid',
+              minWidth: 0,
+              minHeight: 0,
+            }
+          : { display: 'grid' };
+        return (
+          <div key={child.id} style={cell}>
+            {renderNode(child)}
+          </div>
+        );
+      })}
+    </div>
+  );
+};
+
+/** Narrative callouts — caveats, data-quality notes, "read this first". */
+const AlertAdapter: FC<AdapterProps> = ({ resolvedProps, style }) => (
+  <Alert
+    style={style}
+    type={
+      (resolvedProps.type as 'info' | 'success' | 'warning' | 'error') ?? 'info'
+    }
+    message={firstText(resolvedProps, ['message', 'title', 'text', 'label'])}
+    description={resolvedProps.description as ReactNode}
+    showIcon={resolvedProps.showIcon !== false}
+    closable={Boolean(resolvedProps.closable)}
+  />
+);
+
+/** Goal / target tracking — value is bindable to a variable. */
+const ProgressAdapter: FC<AdapterProps> = ({
+  resolvedProps,
+  getBound,
+  style,
+}) => {
+  const bound = getBound('value');
+  const percent = Number(bound ?? resolvedProps.value ?? 0);
+  return (
+    <div style={style}>
+      {resolvedProps.label != null && <div>{String(resolvedProps.label)}</div>}
+      <Progress
+        percent={Number.isFinite(percent) ? percent : 0}
+        type={(resolvedProps.type as 'line' | 'circle' | 'dashboard') ?? 'line'}
+        status={
+          resolvedProps.status as
+            'success' | 'exception' | 'active' | 'normal' | undefined
+        }
+        strokeColor={resolvedProps.strokeColor as string | undefined}
+      />
+    </div>
+  );
+};
+
+/** Collapsible sections — each child becomes a panel titled by its label. */
+const CollapseAdapter: FC<AdapterProps> = ({ node, renderNode, style }) => {
+  const sections = node.children ?? [];
+  return (
+    <Collapse
+      style={style}
+      ghost
+      items={sections.map(section => ({
+        key: section.id,
+        label: String(
+          firstText(section.props ?? {}, ['label', 'title', 'text']) ??
+            section.id,
+        ),
+        children: renderNode(section),
+      }))}
+    />
+  );
+};
+
+/**
+ * A drill-in panel: hidden until an `openModal` action targets it, so a button
+ * can reveal detail charts without leaving the canvas.
+ */
+const ModalAdapter: FC<AdapterProps> = ({ node, resolvedProps, children }) => {
+  const { openModals, setModalOpen } = useUiState();
+  return (
+    <Modal
+      show={Boolean(openModals[node.id])}
+      onHide={() => setModalOpen(node.id, false)}
+      title={firstText(resolvedProps, ['title', 'label', 'text'])}
+      footer={null}
+      width={resolvedProps.width as string | number | undefined}
+      hideFooter
+    >
+      {children}
+    </Modal>
+  );
+};
+
+const InputAdapter: FC<AdapterProps> = ({
+  resolvedProps,
+  fire,
+  getBound,
+  setBound,
+  style,
+}) => {
+  const bound = getBound('value');
+  const value = String(bound ?? (resolvedProps.value as Primitive) ?? '');
+  return (
+    <InlineControl style={style}>
+      {resolvedProps.label != null && (
+        <span>{String(resolvedProps.label)}</span>
+      )}
+      <Input
+        placeholder={resolvedProps.placeholder as string | undefined}
+        value={value}
+        onChange={event => {
+          const next = event.target.value;
+          setBound('value', next);
+          fire('change', next);
+        }}
+      />
+    </InlineControl>
+  );
+};
+
+const SwitchAdapter: FC<AdapterProps> = ({
+  resolvedProps,
+  fire,
+  getBound,
+  setBound,
+  style,
+}) => {
+  const bound = getBound('value');
+  const checked = Boolean(bound ?? resolvedProps.value);
+  return (
+    <InlineControl style={style}>
+      <Switch
+        checked={checked}
+        onChange={(next: boolean) => {
+          setBound('value', next);
+          fire('change', next);
+        }}
+      />
+      {resolvedProps.label != null && (
+        <span>{String(resolvedProps.label)}</span>
+      )}
+    </InlineControl>
+  );
+};
+
+interface FilterOption {
+  value: string | number;
+  label: string;
+}
+
+const FilterAdapter: FC<AdapterProps> = ({ node, resolvedProps, style }) => (
+  <FilterControl
+    style={style}
+    filterId={node.id}
+    column={resolvedProps.column as string}
+    datasetId={resolvedProps.dataset as number | undefined}
+    label={resolvedProps.label as string | undefined}
+    multiple={Boolean(resolvedProps.multiple)}
+    op={resolvedProps.op as CdlFilter['op'] | undefined}
+    options={resolvedProps.options as FilterOption[] | undefined}
+  />
+);
+
+const VizAdapter: FC<AdapterProps> = ({ node, style }) => {
+  if (!isVizNode(node)) {
+    return null;
+  }
+  if (node.renderer === 'echarts') {
+    return <EchartsViz node={node} style={style} />;
+  }
+  return <SupersetChartViz node={node} style={style} />;
+};
+
+export const MANIFEST: Record<string, ManifestEntry> = {
+  Column: { component: ColumnAdapter, catalog: NODE_CATALOG.Column },
+  Row: { component: RowAdapter, catalog: NODE_CATALOG.Row },
+  Card: { component: CardAdapter, catalog: NODE_CATALOG.Card },
+  Tabs: { component: TabsAdapter, catalog: NODE_CATALOG.Tabs },
+  Tab: { component: TabAdapter, catalog: NODE_CATALOG.Tab },
+  Board: { component: BoardAdapter, catalog: NODE_CATALOG.Board },
+  Input: { component: InputAdapter, catalog: NODE_CATALOG.Input },
+  Switch: { component: SwitchAdapter, catalog: NODE_CATALOG.Switch },
+  Divider: { component: DividerAdapter, catalog: NODE_CATALOG.Divider },
+  Alert: { component: AlertAdapter, catalog: NODE_CATALOG.Alert },
+  Progress: { component: ProgressAdapter, catalog: NODE_CATALOG.Progress },
+  Collapse: { component: CollapseAdapter, catalog: NODE_CATALOG.Collapse },
+  Modal: { component: ModalAdapter, catalog: NODE_CATALOG.Modal },
+  Select: { component: SelectAdapter, catalog: NODE_CATALOG.Select },
+  Button: { component: ButtonAdapter, catalog: NODE_CATALOG.Button },
+  Filter: { component: FilterAdapter, catalog: NODE_CATALOG.Filter },
+  Markdown: { component: MarkdownAdapter, catalog: NODE_CATALOG.Markdown },
+  Viz: { component: VizAdapter, catalog: NODE_CATALOG.Viz },
+};
diff --git a/superset-frontend/src/Canvas/queryRunner.ts b/superset-frontend/src/Canvas/queryRunner.ts
new file mode 100644
index 00000000000..4aaa8579dd6
--- /dev/null
+++ b/superset-frontend/src/Canvas/queryRunner.ts
@@ -0,0 +1,118 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { SupersetClient } from '@superset-ui/core';
+import { CdlQueryContext, Primitive } from './types';
+import { QueryResult } from './resolve';
+import { QueryRunner } from './runtime';
+
+type AdhocMetric = {
+  expressionType: 'SQL';
+  sqlExpression: string;
+  label: string;
+  hasCustomLabel: boolean;
+};
+
+/**
+ * A metric string is either a saved-metric name or a SQL aggregate expression
+ * (e.g. `SUM(sales)`). The latter becomes an adhoc SQL metric so the demo works
+ * whether the agent used a saved metric or an inline expression.
+ */
+function toMetric(metric: string): string | AdhocMetric {
+  if (/[()]/.test(metric)) {
+    return {
+      expressionType: 'SQL',
+      sqlExpression: metric,
+      label: metric,
+      hasCustomLabel: false,
+    };
+  }
+  return metric;
+}
+
+function toQueryContext(qc: CdlQueryContext): Record<string, unknown> {
+  return {
+    datasource: { id: qc.datasetId, type: 'table' },
+    force: false,
+    queries: [
+      {
+        columns: qc.groupby ?? [],
+        metrics: qc.metrics.map(toMetric),
+        filters: (qc.filters ?? []).map(f => ({
+          col: f.col,
+          op: f.op,
+          val: f.val,
+        })),
+        row_limit: qc.rowLimit ?? 1000,
+        // query_context orderby is [[metric-or-column, isAscending], ...];
+        // a metric must be the same shape as it appears in `metrics`.
+        orderby: (qc.orderby ?? []).map(({ by, desc }) => [
+          qc.metrics.includes(by) ? toMetric(by) : by,
+          !desc,
+        ]),
+      },
+    ],
+    result_format: 'json',
+    result_type: 'full',
+  };
+}
+
+interface ChartDataPayload {
+  result?: Array<{
+    data?: Array<Record<string, Primitive>>;
+    colnames?: string[];
+    error?: string;
+    status?: string;
+  }>;
+  message?: string;
+}
+
+/**
+ * The real QueryRunner: bound queries go through the governed /api/v1/chart/data
+ * endpoint, inheriting RLS, row limits, and caching. Used by the Canvas viewer.
+ */
+export function createSupersetQueryRunner(): QueryRunner {
+  return {
+    run: async (queryContext: CdlQueryContext): Promise<QueryResult> => {
+      const { json } = await SupersetClient.post({
+        endpoint: '/api/v1/chart/data',
+        jsonPayload: toQueryContext(queryContext),
+      });
+      const payload = json as ChartDataPayload;
+      const first = payload.result?.[0];
+      // The endpoint returns HTTP 200 even when a query fails — the real
+      // message is per-query, so surface it instead of showing "No data".
+      // When the shape is unexpected (async job, error envelope, …) echo the
+      // raw body so we can see exactly what came back.
+      if (!first) {
+        throw new Error(
+          payload.message ??
+            `unexpected response: ${JSON.stringify(json).slice(0, 400)}`,
+        );
+      }
+      if (first.error || first.status === 'failed') {
+        throw new Error(first.error ?? 'query failed');
+      }
+      const records = first.data ?? [];
+      const columns =
+        first.colnames ?? (records[0] ? Object.keys(records[0]) : []);
+      return { columns, records };
+    },
+  };
+}
diff --git a/superset-frontend/src/Canvas/resolve.ts b/superset-frontend/src/Canvas/resolve.ts
new file mode 100644
index 00000000000..bae0cda5816
--- /dev/null
+++ b/superset-frontend/src/Canvas/resolve.ts
@@ -0,0 +1,359 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+  Encoding,
+  EncodingShape,
+  Formatter,
+  Primitive,
+  VariableValues,
+} from './types';
+
+const VAR_REF = /^\$([A-Za-z_][\w]*)$/;
+
+export const isVarRef = (v: unknown): v is string =>
+  typeof v === 'string' && VAR_REF.test(v);
+
+/** Deep-substitute every `$var` reference in a value with its current value. */
+export function resolveVars<T>(value: T, vars: VariableValues): T {
+  if (isVarRef(value)) {
+    return vars[value.slice(1)] as unknown as T;
+  }
+  if (Array.isArray(value)) {
+    return value.map(v => resolveVars(v, vars)) as unknown as T;
+  }
+  if (value && typeof value === 'object') {
+    const out: Record<string, unknown> = {};
+    Object.entries(value as Record<string, unknown>).forEach(([k, v]) => {
+      out[k] = resolveVars(v, vars);
+    });
+    return out as unknown as T;
+  }
+  return value;
+}
+
+export interface QueryResult {
+  columns: string[];
+  records: Array<Record<string, Primitive>>;
+}
+
+const distinct = (values: string[]): string[] => Array.from(new Set(values));
+const toNumber = (v: unknown): number =>
+  typeof v === 'number' ? v : Number(v);
+
+/**
+ * Resolve a requested column name against what the query actually returned.
+ * The AI's `encoding` label (e.g. `SUM(global_sales)`) may not exactly match
+ * the response column, so fall back to a case-insensitive match, a substring
+ * match, or — if only one measure column remains — that column.
+ */
+function resolveColumn(
+  requested: string,
+  columns: string[],
+  exclude: string[],
+): string {
+  if (columns.includes(requested)) {
+    return requested;
+  }
+  const lower = requested.toLowerCase();
+  const caseInsensitive = columns.find(c => c.toLowerCase() === lower);
+  if (caseInsensitive) {
+    return caseInsensitive;
+  }
+  const candidates = columns.filter(c => !exclude.includes(c));
+  const substring = candidates.find(
+    c => c.toLowerCase().includes(lower) || lower.includes(c.toLowerCase()),
+  );
+  if (substring) {
+    return substring;
+  }
+  if (candidates.length === 1) {
+    return candidates[0];
+  }
+  return requested;
+}
+
+const NAME_VALUE_TYPES = new Set([
+  'pie',
+  'funnel',
+  'treemap',
+  'sunburst',
+  'gauge',
+]);
+
+function firstSeriesType(
+  baseOption: Record<string, unknown>,
+): string | undefined {
+  const { series } = baseOption;
+  const first = Array.isArray(series) ? series[0] : series;
+  const type = (first as Record<string, unknown> | undefined)?.type;
+  return typeof type === 'string' ? type : undefined;
+}
+
+/** Infer the data shape from the series type unless the encoding overrides it. */
+function detectShape(
+  baseOption: Record<string, unknown>,
+  encoding: Encoding,
+): EncodingShape {
+  if (encoding.shape) {
+    return encoding.shape;
+  }
+  const type = firstSeriesType(baseOption);
+  if (type === 'scatter' || type === 'effectScatter') {
+    return 'pairs';
+  }
+  if (type === 'heatmap') {
+    return 'matrix';
+  }
+  if (type === 'radar') {
+    return 'radar';
+  }
+  if (type && NAME_VALUE_TYPES.has(type)) {
+    return 'nameValue';
+  }
+  return 'categoryValue';
+}
+
+/**
+ * Map a query result onto an echarts option using the declarative `encoding`.
+ * The base `option` supplies presentation (series type, axis config); we fill in
+ * `xAxis.data` and `series[].data`.
+ */
+export function encodeToOption(
+  baseOption: Record<string, unknown>,
+  encoding: Encoding,
+  result: QueryResult,
+): Record<string, unknown> {
+  const { x, y, series } = encoding;
+  const rows = result.records;
+  const columns = result.columns.length
+    ? result.columns
+    : Object.keys(rows[0] ?? {});
+
+  // Reconcile the encoding's labels with the actual result columns.
+  const xKey = resolveColumn(x, columns, []);
+  const seriesKey = series ? resolveColumn(series, columns, [xKey]) : undefined;
+  const exclude = seriesKey ? [xKey, seriesKey] : [xKey];
+  const metricKeys = (Array.isArray(y) ? y : [y]).map(metric =>
+    resolveColumn(metric, columns, exclude),
+  );
+
+  const categories = distinct(rows.map(r => String(r[xKey])));
+
+  const baseSeriesList = Array.isArray(baseOption.series)
+    ? (baseOption.series as Array<Record<string, unknown>>)
+    : [(baseOption.series as Record<string, unknown>) ?? {}];
+  const withBase = (index: number, spec: Record<string, unknown>) => ({
+    ...baseSeriesList[index % baseSeriesList.length],
+    ...spec,
+  });
+  const axis = (key: 'xAxis' | 'yAxis') =>
+    (baseOption[key] as Record<string, unknown>) ?? {};
+  const valueAt = (cat: string, metric: string): number | null => {
+    const row = rows.find(r => String(r[xKey]) === cat);
+    return row ? toNumber(row[metric]) : null;
+  };
+
+  switch (detectShape(baseOption, encoding)) {
+    // pie / funnel / treemap / sunburst / gauge — no axes, [{name, value}]
+    case 'nameValue': {
+      const metric = metricKeys[0];
+      return {
+        ...baseOption,
+        series: [
+          withBase(0, {
+            name: metric,
+            data: categories.map(cat => ({
+              name: cat,
+              value: valueAt(cat, metric),
+            })),
+          }),
+        ],
+      };
+    }
+    // scatter — [[xMetric, yMetric, categoryLabel]]
+    case 'pairs': {
+      const [mx, my = metricKeys[0]] = metricKeys;
+      return {
+        ...baseOption,
+        xAxis: { type: 'value', ...axis('xAxis') },
+        yAxis: { type: 'value', ...axis('yAxis') },
+        series: [
+          withBase(0, {
+            name: `${mx} vs ${my}`,
+            data: rows.map(r => [
+              toNumber(r[mx]),
+              toNumber(r[my]),
+              String(r[xKey]),
+            ]),
+          }),
+        ],
+      };
+    }
+    // heatmap — [[xIndex, yIndex, value]] across x and the series column
+    case 'matrix': {
+      const metric = metricKeys[0];
+      const yCats = seriesKey
+        ? distinct(rows.map(r => String(r[seriesKey])))
+        : [];
+      return {
+        ...baseOption,
+        xAxis: { type: 'category', ...axis('xAxis'), data: categories },
+        yAxis: { type: 'category', ...axis('yAxis'), data: yCats },
+        series: [
+          withBase(0, {
+            name: metric,
+            data: rows.map(r => [
+              categories.indexOf(String(r[xKey])),
+              seriesKey ? yCats.indexOf(String(r[seriesKey])) : 0,
+              toNumber(r[metric]),
+            ]),
+          }),
+        ],
+      };
+    }
+    // radar — categories become indicators, one ring per metric
+    case 'radar': {
+      const values = metricKeys.flatMap(metric =>
+        categories.map(cat => valueAt(cat, metric) ?? 0),
+      );
+      const max = Math.max(1, ...values);
+      return {
+        ...baseOption,
+        radar: {
+          ...((baseOption.radar as Record<string, unknown>) ?? {}),
+          indicator: categories.map(cat => ({ name: cat, max })),
+        },
+        series: [
+          withBase(0, {
+            type: 'radar',
+            data: metricKeys.map(metric => ({
+              name: metric,
+              value: categories.map(cat => valueAt(cat, metric) ?? 0),
+            })),
+          }),
+        ],
+      };
+    }
+    default:
+      break;
+  }
+
+  let seriesData: Array<{ name: string; data: Array<number | null> }>;
+  if (seriesKey) {
+    const groups = distinct(rows.map(r => String(r[seriesKey])));
+    seriesData = groups.map(group => ({
+      name: group,
+      data: categories.map(cat => {
+        const row = rows.find(
+          r => String(r[xKey]) === cat && String(r[seriesKey]) === group,
+        );
+        return row ? toNumber(row[metricKeys[0]]) : null;
+      }),
+    }));
+  } else {
+    seriesData = metricKeys.map(metric => ({
+      name: metric,
+      data: categories.map(cat => {
+        const row = rows.find(r => String(r[xKey]) === cat);
+        return row ? toNumber(row[metric]) : null;
+      }),
+    }));
+  }
+
+  const baseSeries = Array.isArray(baseOption.series)
+    ? (baseOption.series as Array<Record<string, unknown>>)
+    : [(baseOption.series as Record<string, unknown>) ?? {}];
+  const mergedSeries = seriesData.map((s, i) => ({
+    ...baseSeries[i % baseSeries.length],
+    ...s,
+  }));
+
+  const baseXAxis = (baseOption.xAxis as Record<string, unknown>) ?? {
+    type: 'category',
+  };
+  return {
+    ...baseOption,
+    xAxis: { ...baseXAxis, data: categories },
+    series: mergedSeries,
+  };
+}
+
+const FORMATTER_KINDS = new Set<Formatter['kind']>([
+  'number',
+  'currency',
+  'percent',
+  'date',
+  'template',
+]);
+
+function makeFormatter(f: Formatter): (v: unknown) => string {
+  switch (f.kind) {
+    case 'currency': {
+      const nf = new Intl.NumberFormat(undefined, {
+        style: 'currency',
+        currency: f.currency,
+        maximumFractionDigits: f.decimals ?? 0,
+      });
+      return v => nf.format(toNumber(v));
+    }
+    case 'number': {
+      const nf = new Intl.NumberFormat(undefined, {
+        maximumFractionDigits: f.decimals ?? 2,
+      });
+      return v => nf.format(toNumber(v));
+    }
+    case 'percent':
+      return v => `${(toNumber(v) * 100).toFixed(f.decimals ?? 0)}%`;
+    case 'date':
+      return v => String(v);
+    case 'template':
+      return v => f.template.replace('{value}', String(v));
+    default:
+      return v => String(v);
+  }
+}
+
+const isFormatterSpec = (v: unknown): v is Formatter =>
+  !!v &&
+  typeof v === 'object' &&
+  typeof (v as { kind?: unknown }).kind === 'string' &&
+  FORMATTER_KINDS.has((v as Formatter).kind);
+
+/**
+ * Convert declarative formatter objects in an option into real functions — the
+ * only place functions are ever produced, and only at render time from a fixed
+ * vocabulary, never from persisted strings.
+ */
+export function resolveFormatters(value: unknown): unknown {
+  if (isFormatterSpec(value)) {
+    return makeFormatter(value);
+  }
+  if (Array.isArray(value)) {
+    return value.map(resolveFormatters);
+  }
+  if (value && typeof value === 'object') {
+    const out: Record<string, unknown> = {};
+    Object.entries(value as Record<string, unknown>).forEach(([k, v]) => {
+      out[k] = resolveFormatters(v);
+    });
+    return out;
+  }
+  return value;
+}
diff --git a/superset-frontend/src/Canvas/runtime.tsx b/superset-frontend/src/Canvas/runtime.tsx
new file mode 100644
index 00000000000..439baa58f0d
--- /dev/null
+++ b/superset-frontend/src/Canvas/runtime.tsx
@@ -0,0 +1,314 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+  createContext,
+  ReactNode,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+} from 'react';
+import {
+  CdlFilter,
+  CdlQueryContext,
+  Primitive,
+  VariableDecl,
+  VariableValues,
+} from './types';
+import { QueryResult, resolveVars } from './resolve';
+
+/* -------------------------------------------------------------------------- */
+/* Variable store — the reactive spine.                                       */
+/*                                                                            */
+/* In-app, `query`-scoped variables project onto the host dashboard dataMask  */
+/* (governed re-queries, caching, RLS). The prototype keeps them in React     */
+/* state; `dataMaskSink` is the seam where the real dataMask dispatch plugs in.*/
+/* -------------------------------------------------------------------------- */
+
+interface VariableStore {
+  vars: VariableValues;
+  decls: Record<string, VariableDecl>;
+  setVariable: (name: string, value: Primitive) => void;
+  reset: () => void;
+}
+
+const VariableContext = createContext<VariableStore | undefined>(undefined);
+
+export interface VariableProviderProps {
+  variables: Record<string, VariableDecl>;
+  children: ReactNode;
+  /** Seam: receives every write so query-scoped vars can be pushed to dataMask. */
+  dataMaskSink?: (
+    name: string,
+    value: Primitive,
+    scope: VariableDecl['scope'],
+  ) => void;
+}
+
+export function VariableProvider({
+  variables,
+  children,
+  dataMaskSink,
+}: VariableProviderProps) {
+  const initial = useMemo<VariableValues>(
+    () =>
+      Object.fromEntries(
+        Object.entries(variables).map(([name, decl]) => [name, decl.default]),
+      ),
+    [variables],
+  );
+  const [vars, setVars] = useState<VariableValues>(initial);
+
+  const setVariable = useCallback(
+    (name: string, value: Primitive) => {
+      setVars(prev => ({ ...prev, [name]: value }));
+      dataMaskSink?.(name, value, variables[name]?.scope ?? 'ui');
+    },
+    [dataMaskSink, variables],
+  );
+
+  const reset = useCallback(() => setVars(initial), [initial]);
+
+  const value = useMemo<VariableStore>(
+    () => ({ vars, decls: variables, setVariable, reset }),
+    [vars, variables, setVariable, reset],
+  );
+
+  return (
+    <VariableContext.Provider value={value}>
+      {children}
+    </VariableContext.Provider>
+  );
+}
+
+export function useVariables(): VariableStore {
+  const ctx = useContext(VariableContext);
+  if (!ctx) {
+    throw new Error('useVariables must be used within a VariableProvider');
+  }
+  return ctx;
+}
+
+/* -------------------------------------------------------------------------- */
+/* Query runner — the data seam.                                              */
+/*                                                                            */
+/* The prototype injects a runner (mock or real). The real runner maps a      */
+/* CdlQueryContext to a Superset query_context and POSTs /api/v1/chart/data,  */
+/* inheriting RLS/caching. Bound Viz nodes re-fetch when a referenced         */
+/* variable changes.                                                          */
+/* -------------------------------------------------------------------------- */
+
+export interface QueryRunner {
+  run: (queryContext: CdlQueryContext) => Promise<QueryResult>;
+}
+
+const QueryRunnerContext = createContext<QueryRunner | undefined>(undefined);
+
+export function QueryRunnerProvider({
+  runner,
+  children,
+}: {
+  runner: QueryRunner;
+  children: ReactNode;
+}) {
+  return (
+    <QueryRunnerContext.Provider value={runner}>
+      {children}
+    </QueryRunnerContext.Provider>
+  );
+}
+
+export function useQueryRunner(): QueryRunner {
+  const ctx = useContext(QueryRunnerContext);
+  if (!ctx) {
+    throw new Error('useQueryRunner must be used within a QueryRunnerProvider');
+  }
+  return ctx;
+}
+
+/* -------------------------------------------------------------------------- */
+/* Filter store — canvas-global filters (the native-filter analogue).         */
+/*                                                                            */
+/* A Filter node writes an entry here; every bound Viz on the same dataset    */
+/* merges the active filters into its query — so the AI places one filter and */
+/* it applies across charts without wiring each query by hand.                */
+/* -------------------------------------------------------------------------- */
+
+export interface ActiveFilter {
+  /** When set, the filter only applies to queries on this dataset. */
+  datasetId?: number;
+  filter: CdlFilter;
+}
+
+interface FilterStore {
+  filters: Record<string, ActiveFilter | null>;
+  setFilter: (id: string, value: ActiveFilter | null) => void;
+  clearFilters: () => void;
+}
+
+const FilterContext = createContext<FilterStore | undefined>(undefined);
+
+export function FilterProvider({ children }: { children: ReactNode }) {
+  const [filters, setFilters] = useState<Record<string, ActiveFilter | null>>(
+    {},
+  );
+  const setFilter = useCallback(
+    (id: string, value: ActiveFilter | null) =>
+      setFilters(prev => ({ ...prev, [id]: value })),
+    [],
+  );
+  const clearFilters = useCallback(() => setFilters({}), []);
+  const value = useMemo<FilterStore>(
+    () => ({ filters, setFilter, clearFilters }),
+    [filters, setFilter, clearFilters],
+  );
+  return (
+    <FilterContext.Provider value={value}>{children}</FilterContext.Provider>
+  );
+}
+
+export function useFilters(): FilterStore {
+  const ctx = useContext(FilterContext);
+  if (!ctx) {
+    throw new Error('useFilters must be used within a FilterProvider');
+  }
+  return ctx;
+}
+
+/** The active filters applicable to a query on `datasetId`. */
+export function useActiveFilters(datasetId: number): CdlFilter[] {
+  const { filters } = useFilters();
+  return useMemo(
+    () =>
+      Object.values(filters)
+        .filter((f): f is ActiveFilter => f != null)
+        .filter(f => f.datasetId === undefined || f.datasetId === datasetId)
+        .map(f => f.filter),
+    [filters, datasetId],
+  );
+}
+
+/* -------------------------------------------------------------------------- */
+/* UI state — active tabs and a refresh nonce.                                */
+/*                                                                            */
+/* Backs the `navigateTab` and `refresh` actions so the bounded action        */
+/* vocabulary is fully functional rather than partly stubbed.                 */
+/* -------------------------------------------------------------------------- */
+
+interface UiState {
+  activeTabs: Record<string, string>;
+  setActiveTab: (tabsId: string, tab: string) => void;
+  /** Modal nodes are hidden until an `openModal` action opens them. */
+  openModals: Record<string, boolean>;
+  setModalOpen: (modalId: string, open: boolean) => void;
+  /** Bumped by the `refresh` action; participates in every query cache key. */
+  refreshNonce: number;
+  refresh: () => void;
+}
+
+const UiStateContext = createContext<UiState | undefined>(undefined);
+
+export function UiStateProvider({ children }: { children: ReactNode }) {
+  const [activeTabs, setActiveTabs] = useState<Record<string, string>>({});
+  const [openModals, setOpenModals] = useState<Record<string, boolean>>({});
+  const [refreshNonce, setRefreshNonce] = useState(0);
+  const setActiveTab = useCallback(
+    (tabsId: string, tab: string) =>
+      setActiveTabs(prev => ({ ...prev, [tabsId]: tab })),
+    [],
+  );
+  const setModalOpen = useCallback(
+    (modalId: string, open: boolean) =>
+      setOpenModals(prev => ({ ...prev, [modalId]: open })),
+    [],
+  );
+  const refresh = useCallback(() => setRefreshNonce(n => n + 1), []);
+  const value = useMemo<UiState>(
+    () => ({
+      activeTabs,
+      setActiveTab,
+      openModals,
+      setModalOpen,
+      refreshNonce,
+      refresh,
+    }),
+    [activeTabs, setActiveTab, openModals, setModalOpen, refreshNonce, refresh],
+  );
+  return (
+    <UiStateContext.Provider value={value}>{children}</UiStateContext.Provider>
+  );
+}
+
+export function useUiState(): UiState {
+  const ctx = useContext(UiStateContext);
+  if (!ctx) {
+    throw new Error('useUiState must be used within a UiStateProvider');
+  }
+  return ctx;
+}
+
+export interface BoundQueryState {
+  loading: boolean;
+  error?: string;
+  result?: QueryResult;
+}
+
+/**
+ * Resolve `$vars` in a query context against the live store, merge in active
+ * canvas filters, fetch, and re-fetch whenever the resolved context changes.
+ */
+export function useBoundQuery(queryContext: CdlQueryContext): BoundQueryState {
+  const runner = useQueryRunner();
+  const { vars } = useVariables();
+  const { refreshNonce } = useUiState();
+  const activeFilters = useActiveFilters(queryContext.datasetId);
+  const resolved = useMemo(() => {
+    const base = resolveVars(queryContext, vars);
+    return { ...base, filters: [...(base.filters ?? []), ...activeFilters] };
+  }, [queryContext, vars, activeFilters]);
+  const key = `${JSON.stringify(resolved)}|${refreshNonce}`;
+  const [state, setState] = useState<BoundQueryState>({ loading: true });
+
+  useEffect(() => {
+    let live = true;
+    setState({ loading: true });
+    runner
+      .run(resolved)
+      .then(result => {
+        if (live) setState({ loading: false, result });
+      })
+      .catch((error: unknown) => {
+        if (live) {
+          setState({
+            loading: false,
+            error: error instanceof Error ? error.message : String(error),
+          });
+        }
+      });
+    return () => {
+      live = false;
+    };
+    // `key` captures the resolved context; runner is stable.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [key]);
+
+  return state;
+}
diff --git a/superset-frontend/src/Canvas/style.ts b/superset-frontend/src/Canvas/style.ts
new file mode 100644
index 00000000000..805a01621a7
--- /dev/null
+++ b/superset-frontend/src/Canvas/style.ts
@@ -0,0 +1,177 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { CSSProperties } from 'react';
+import { CdlStyle } from './types';
+
+/**
+ * Styling stays inside the no-code invariant: a node's `style` is a data object
+ * (never a CSS string), restricted to an allowlist of layout/appearance
+ * properties. Values may reference antd theme tokens as `@tokenName`, so
+ * AI-authored styling still respects light/dark theming.
+ */
+export const STYLE_PROPERTIES: ReadonlySet<string> = new Set([
+  // spacing
+  'padding',
+  'paddingTop',
+  'paddingRight',
+  'paddingBottom',
+  'paddingLeft',
+  'margin',
+  'marginTop',
+  'marginRight',
+  'marginBottom',
+  'marginLeft',
+  'gap',
+  'rowGap',
+  'columnGap',
+  // sizing
+  'width',
+  'minWidth',
+  'maxWidth',
+  'height',
+  'minHeight',
+  'maxHeight',
+  // surface
+  'background',
+  'backgroundColor',
+  'color',
+  'border',
+  'borderColor',
+  'borderWidth',
+  'borderStyle',
+  'borderRadius',
+  'boxShadow',
+  'opacity',
+  'overflow',
+  // typography
+  'fontSize',
+  'fontWeight',
+  'fontFamily',
+  'lineHeight',
+  'letterSpacing',
+  'textAlign',
+  'textTransform',
+  // layout
+  'display',
+  'flex',
+  'flexDirection',
+  'flexWrap',
+  'alignItems',
+  'justifyContent',
+  'alignSelf',
+  'gridTemplateColumns',
+  'gridTemplateRows',
+  'gridColumn',
+  'gridRow',
+  // freeform positioning + transforms (overlap, rotation, pinning)
+  'position',
+  'top',
+  'right',
+  'bottom',
+  'left',
+  'inset',
+  'zIndex',
+  'transform',
+  'transformOrigin',
+  'rotate',
+  'scale',
+  'translate',
+  'transition',
+  'aspectRatio',
+  'objectFit',
+  'filter',
+  'backdropFilter',
+  'mixBlendMode',
+  'cursor',
+  'pointerEvents',
+]);
+
+/** Values that could smuggle behaviour or external fetches into CSS. */
+const UNSAFE_VALUE = /url\(|expression\(|javascript:|@import|<|\/\*/i;
+
+const TOKEN_REF = /^@([A-Za-z_][\w]*)$/;
+
+export const isTokenRef = (value: unknown): value is string =>
+  typeof value === 'string' && TOKEN_REF.test(value);
+
+/**
+ * Validate a node's `style`. Returns human-readable errors (empty == valid).
+ * Shared shape with the server-side validator in
+ * superset/mcp_service/canvas/validation.py.
+ */
+export function validateStyle(style: unknown, path: string): string[] {
+  const errors: string[] = [];
+  if (style === undefined) {
+    return errors;
+  }
+  if (typeof style !== 'object' || style === null || Array.isArray(style)) {
+    errors.push(`${path}: style must be an object`);
+    return errors;
+  }
+  Object.entries(style as Record<string, unknown>).forEach(([prop, value]) => {
+    if (!STYLE_PROPERTIES.has(prop)) {
+      errors.push(`${path}.${prop}: unsupported style property`);
+      return;
+    }
+    if (typeof value !== 'string' && typeof value !== 'number') {
+      errors.push(`${path}.${prop}: style values must be a string or number`);
+      return;
+    }
+    if (typeof value === 'string' && UNSAFE_VALUE.test(value)) {
+      errors.push(`${path}.${prop}: disallowed value`);
+    }
+  });
+  return errors;
+}
+
+/**
+ * Resolve a validated `style` into React inline styles, substituting
+ * `@tokenName` references from the active theme. Unknown properties and unsafe
+ * values are dropped rather than thrown, so a bad style never breaks a render.
+ */
+export function resolveStyle(
+  style: CdlStyle | undefined,
+  theme: Record<string, unknown>,
+): CSSProperties | undefined {
+  if (!style) {
+    return undefined;
+  }
+  const out: Record<string, string | number> = {};
+  Object.entries(style).forEach(([prop, value]) => {
+    if (!STYLE_PROPERTIES.has(prop)) {
+      return;
+    }
+    if (typeof value === 'string' && UNSAFE_VALUE.test(value)) {
+      return;
+    }
+    if (isTokenRef(value)) {
+      const token = value.slice(1);
+      const resolved = theme[token];
+      if (typeof resolved === 'string' || typeof resolved === 'number') {
+        out[prop] = resolved;
+      }
+      return;
+    }
+    if (typeof value === 'string' || typeof value === 'number') {
+      out[prop] = value;
+    }
+  });
+  return Object.keys(out).length ? (out as CSSProperties) : undefined;
+}
diff --git a/superset-frontend/src/Canvas/types.ts b/superset-frontend/src/Canvas/types.ts
new file mode 100644
index 00000000000..fb531c24932
--- /dev/null
+++ b/superset-frontend/src/Canvas/types.ts
@@ -0,0 +1,189 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * CDL (Canvas Definition Language) — the typed, declarative tree an AI (or a
+ * human) emits to describe a v2 dashboard. Nothing here is executable code:
+ * presentation is a data `option`, data is a `queryContext` + `encoding`, and
+ * behaviour is a bounded `action` enum. See canvas-v2-design.md.
+ */
+
+export type Primitive = string | number | boolean;
+
+/** A reference to a declared variable, written `$name` anywhere a value is expected. */
+export type VarRef = `$${string}`;
+
+export type VariableScope = 'query' | 'ui';
+
+export interface VariableDecl {
+  type: 'string' | 'number' | 'boolean';
+  default: Primitive;
+  /**
+   * `query` variables project onto the host dashboard's dataMask (governed,
+   * re-queries bound Viz nodes). `ui` variables live client-side only.
+   */
+  scope: VariableScope;
+}
+
+export type VariableValues = Record<string, Primitive>;
+
+/** A value that may be a literal or a `$var` reference resolved at render time. */
+export type Bindable<T extends Primitive> = T | VarRef;
+
+export interface CdlFilter {
+  col: string;
+  op: '==' | '!=' | '>' | '<' | '>=' | '<=' | 'IN' | 'LIKE';
+  /** May be a literal or a `$var` reference. */
+  val: Primitive | Primitive[] | VarRef;
+}
+
+/** Declarative formatter vocabulary — resolved into a real function client-side. */
+export type Formatter =
+  | { kind: 'number'; decimals?: number }
+  | { kind: 'currency'; currency: string; decimals?: number }
+  | { kind: 'percent'; decimals?: number }
+  | { kind: 'date'; format?: string }
+  | { kind: 'template'; template: string };
+
+/** Minimal query spec; expands into a Superset query_context at fetch time. */
+export interface CdlQueryContext {
+  datasetId: number;
+  metrics: string[];
+  groupby?: string[];
+  filters?: CdlFilter[];
+  rowLimit?: number;
+  /**
+   * Sort the result. Combine with `rowLimit` for top-N charts, e.g.
+   * `[{ by: 'SUM(global_sales)', desc: true }]` with `rowLimit: 10`.
+   * `by` may name a metric (from `metrics`) or a groupby column.
+   */
+  orderby?: Array<{ by: string; desc?: boolean }>;
+}
+
+/**
+ * How the query result is shaped onto the echarts series. Inferred from the
+ * series type when omitted:
+ *  - `categoryValue` — xAxis categories + numeric series (bar, line, area)
+ *  - `nameValue`     — [{name, value}] (pie, funnel, treemap, sunburst, gauge)
+ *  - `pairs`         — [[x, y]] from two metrics (scatter)
+ *  - `matrix`        — [[xIndex, yIndex, value]] (heatmap; needs `series`)
+ *  - `radar`         — indicators from categories, one ring per metric
+ */
+export type EncodingShape =
+  'categoryValue' | 'nameValue' | 'pairs' | 'matrix' | 'radar';
+
+/** How query result columns map onto an echarts option. */
+export interface Encoding {
+  /** Category/x dimension column. */
+  x: string;
+  /** Value column(s) for the series. */
+  y: string | string[];
+  /** Optional column whose distinct values fan out into one series each. */
+  series?: string | null;
+  /** Override the inferred data shape. */
+  shape?: EncodingShape;
+}
+
+export type CdlAction =
+  | { action: 'setVariable'; name: string; value: Primitive | VarRef }
+  | {
+      action: 'applyFilter';
+      col: string;
+      op: CdlFilter['op'];
+      val: CdlFilter['val'];
+    }
+  | {
+      action: 'crossFilter';
+      col: string;
+      op: CdlFilter['op'];
+      val: CdlFilter['val'];
+    }
+  | { action: 'clearFilters'; scope?: VariableScope }
+  | { action: 'navigateTab'; tabsId: string; tab: string }
+  | { action: 'openModal'; modalId: string }
+  | { action: 'closeModal'; modalId: string }
+  | { action: 'openUrl'; url: string; newTab?: boolean }
+  | { action: 'refresh'; target?: string };
+
+export type CdlActionName = CdlAction['action'];
+
+/**
+ * Declarative styling: an allowlisted CSS-property object (never a CSS string).
+ * Values may be literals or `@themeToken` references resolved from the antd
+ * theme at render time. See style.ts.
+ */
+export type CdlStyle = Record<string, string | number>;
+
+/**
+ * Placement for a node inside a `Board` (freeform) container, in grid units:
+ * `x`/`y` are the top-left cell (0-based), `w`/`h` the span, `z` the stacking
+ * order for deliberate overlap. Ignored outside a Board.
+ */
+export interface BoardLayout {
+  x: number;
+  y: number;
+  w: number;
+  h: number;
+  z?: number;
+}
+
+export interface BaseNode {
+  /** Stable id — AI-addressable for targeted edits/diffs. */
+  id: string;
+  type: string;
+  props?: Record<string, unknown>;
+  /** Allowlisted inline styling, theme-token aware. */
+  style?: CdlStyle;
+  /** Position within a parent Board (grid units). */
+  layout?: BoardLayout;
+  /** Two-way binding of a prop to a `$var`. */
+  bind?: Record<string, VarRef>;
+  /** Declarative event handlers: event name -> ordered action list. */
+  on?: Record<string, CdlAction[]>;
+  children?: CdlNode[];
+}
+
+export interface VizNode extends BaseNode {
+  type: 'Viz';
+  renderer: 'echarts' | 'supersetChart';
+  /** supersetChart: reference an existing governed Slice. */
+  chartId?: number;
+  /** supersetChart: extra dataMask filters. */
+  filters?: CdlFilter[];
+  /** echarts: bound data spec. */
+  data?: { queryContext: CdlQueryContext; encoding: Encoding };
+  /** echarts: the presentation option (data, not code). */
+  option?: Record<string, unknown>;
+}
+
+export type CdlNode = BaseNode | VizNode;
+
+export interface CanvasDefinition {
+  cdlVersion: number;
+  variables: Record<string, VariableDecl>;
+  tree: CdlNode;
+  /**
+   * Outer width cap. `"full"` (default) is full-bleed like a dashboard; a CSS
+   * width (e.g. `"820px"`) centres a narrower reading measure for documents.
+   */
+  canvasWidth?: string;
+}
+
+export const isVizNode = (node: CdlNode): node is VizNode =>
+  node.type === 'Viz';
diff --git a/superset-frontend/src/Canvas/validator.ts b/superset-frontend/src/Canvas/validator.ts
new file mode 100644
index 00000000000..cc54356e0b6
--- /dev/null
+++ b/superset-frontend/src/Canvas/validator.ts
@@ -0,0 +1,326 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { NODE_CATALOG, isKnownType } from './catalog';
+import { validateStyle } from './style';
+import {
+  CanvasDefinition,
+  CdlAction,
+  CdlActionName,
+  CdlNode,
+  isVizNode,
+} from './types';
+
+export interface ValidationError {
+  path: string;
+  message: string;
+}
+
+export interface ValidationResult {
+  valid: boolean;
+  errors: ValidationError[];
+}
+
+const VAR_REF = /^\$([A-Za-z_][\w]*)$/;
+const EVENT_TOKEN = '$event';
+/** The no-code invariant: reject anything that smells like an executable string. */
+const CODE_SMELL = /=>|\bfunction\b|new\s+Function/i;
+const JS_URL = /^\s*(javascript|data|vbscript):/i;
+
+/**
+ * Keyed by the action union, so adding an action to `CdlAction` without
+ * declaring its required params is a compile error rather than a runtime
+ * "unknown action" that only shows up in the browser.
+ */
+const ACTION_REQUIRED: Record<CdlActionName, string[]> = {
+  setVariable: ['name', 'value'],
+  applyFilter: ['col', 'op', 'val'],
+  crossFilter: ['col', 'op', 'val'],
+  clearFilters: [],
+  navigateTab: ['tabsId', 'tab'],
+  openModal: ['modalId'],
+  closeModal: ['modalId'],
+  openUrl: ['url'],
+  refresh: [],
+};
+
+const isVarRef = (v: unknown): v is string =>
+  typeof v === 'string' && VAR_REF.test(v);
+
+const varName = (ref: string): string => ref.slice(1);
+
+/** Deep-scan a value for executable-looking strings (the core safety gate). */
+function scanNoCode(
+  value: unknown,
+  path: string,
+  errors: ValidationError[],
+): void {
+  if (typeof value === 'string') {
+    if (CODE_SMELL.test(value)) {
+      errors.push({
+        path,
+        message: `disallowed executable string (no-code invariant): ${value.slice(0, 40)}`,
+      });
+    }
+    return;
+  }
+  if (Array.isArray(value)) {
+    value.forEach((v, i) => scanNoCode(v, `${path}[${i}]`, errors));
+    return;
+  }
+  if (value && typeof value === 'object') {
+    Object.entries(value as Record<string, unknown>).forEach(([k, v]) => {
+      // A `formatter` must be a declarative object, never a raw string/function.
+      if (k === 'formatter' && typeof v === 'string') {
+        errors.push({
+          path: `${path}.formatter`,
+          message: 'formatter must be a declarative object, not a string',
+        });
+      }
+      scanNoCode(v, `${path}.${k}`, errors);
+    });
+  }
+}
+
+/** Collect every `$var` reference in a value (excluding the `$event` token). */
+function collectRefs(value: unknown, out: Set<string>): void {
+  if (isVarRef(value) && value !== EVENT_TOKEN) {
+    out.add(varName(value));
+  } else if (Array.isArray(value)) {
+    value.forEach(v => collectRefs(v, out));
+  } else if (value && typeof value === 'object') {
+    Object.values(value as Record<string, unknown>).forEach(v =>
+      collectRefs(v, out),
+    );
+  }
+}
+
+function validateAction(
+  action: CdlAction,
+  path: string,
+  declared: Set<string>,
+  errors: ValidationError[],
+): void {
+  const required = ACTION_REQUIRED[action.action];
+  if (!required) {
+    errors.push({ path, message: `unknown action "${action.action}"` });
+    return;
+  }
+  const record = action as unknown as Record<string, unknown>;
+  required.forEach(key => {
+    if (record[key] === undefined) {
+      errors.push({
+        path,
+        message: `action "${action.action}" missing "${key}"`,
+      });
+    }
+  });
+  if (action.action === 'openUrl' && JS_URL.test(action.url)) {
+    errors.push({
+      path: `${path}.url`,
+      message: 'openUrl allows http(s) only',
+    });
+  }
+  if (action.action === 'setVariable' && !declared.has(action.name)) {
+    errors.push({
+      path: `${path}.name`,
+      message: `undeclared variable "${action.name}"`,
+    });
+  }
+}
+
+function validateNode(
+  node: CdlNode,
+  path: string,
+  declared: Set<string>,
+  errors: ValidationError[],
+): void {
+  if (!node || typeof node !== 'object') {
+    errors.push({ path, message: 'node must be an object' });
+    return;
+  }
+  if (typeof node.id !== 'string' || !node.id) {
+    errors.push({ path, message: 'node.id (string) is required' });
+  }
+  if (!isKnownType(node.type)) {
+    errors.push({
+      path,
+      message: `unknown node type "${node.type}" (not in catalog)`,
+    });
+    return; // can't validate further against an unknown contract
+  }
+  const entry = NODE_CATALOG[node.type];
+
+  // Required props
+  entry.requiredProps.forEach(prop => {
+    if (node.props?.[prop] === undefined) {
+      errors.push({
+        path: `${path}.props.${prop}`,
+        message: `required prop "${prop}" missing`,
+      });
+    }
+  });
+
+  // Children only on containers
+  if (node.children?.length && !entry.container) {
+    errors.push({
+      path: `${path}.children`,
+      message: `"${node.type}" cannot have children`,
+    });
+  }
+
+  // bind targets must be bindable + reference declared vars
+  Object.entries(node.bind ?? {}).forEach(([prop, ref]) => {
+    if (!entry.bindableProps.includes(prop)) {
+      errors.push({
+        path: `${path}.bind.${prop}`,
+        message: `prop "${prop}" is not bindable`,
+      });
+    }
+    if (!isVarRef(ref)) {
+      errors.push({
+        path: `${path}.bind.${prop}`,
+        message: `bind must be a $var reference`,
+      });
+    } else if (!declared.has(varName(ref))) {
+      errors.push({
+        path: `${path}.bind.${prop}`,
+        message: `undeclared variable "${ref}"`,
+      });
+    }
+  });
+
+  // Events must be in the catalog; actions must be valid
+  Object.entries(node.on ?? {}).forEach(([event, actions]) => {
+    if (!entry.events.includes(event)) {
+      errors.push({
+        path: `${path}.on.${event}`,
+        message: `"${node.type}" does not emit "${event}"`,
+      });
+    }
+    (actions ?? []).forEach((action, i) =>
+      validateAction(action, `${path}.on.${event}[${i}]`, declared, errors),
+    );
+  });
+
+  // Viz-specific
+  if (isVizNode(node)) {
+    if (node.renderer === 'echarts') {
+      if (!node.data?.queryContext) {
+        errors.push({
+          path: `${path}.data`,
+          message: 'echarts Viz requires data.queryContext',
+        });
+      }
+      if (!node.data?.encoding) {
+        errors.push({
+          path: `${path}.data`,
+          message: 'echarts Viz requires data.encoding',
+        });
+      }
+      scanNoCode(node.option, `${path}.option`, errors);
+    } else if (node.renderer === 'supersetChart') {
+      if (typeof node.chartId !== 'number') {
+        errors.push({
+          path: `${path}.chartId`,
+          message: 'supersetChart Viz requires chartId',
+        });
+      }
+    } else {
+      errors.push({
+        path: `${path}.renderer`,
+        message: `unknown Viz renderer`,
+      });
+    }
+  }
+
+  // No-code scan over props (option scanned above for Viz)
+  scanNoCode(node.props, `${path}.props`, errors);
+
+  // Declarative styling: allowlisted properties, safe values only.
+  validateStyle(node.style, `${path}.style`).forEach(message =>
+    errors.push({ path: `${path}.style`, message }),
+  );
+
+  // Board placement: numeric grid units.
+  if (node.layout !== undefined) {
+    const layout = node.layout as unknown as Record<string, unknown>;
+    (['x', 'y', 'w', 'h'] as const).forEach(key => {
+      if (typeof layout[key] !== 'number') {
+        errors.push({
+          path: `${path}.layout.${key}`,
+          message: `${key} must be a number`,
+        });
+      }
+    });
+    if (typeof layout.w === 'number' && layout.w < 1) {
+      errors.push({
+        path: `${path}.layout.w`,
+        message: 'w must be at least 1',
+      });
+    }
+    if (typeof layout.h === 'number' && layout.h < 1) {
+      errors.push({
+        path: `${path}.layout.h`,
+        message: 'h must be at least 1',
+      });
+    }
+  }
+
+  // Reference integrity across props / bind / on / data
+  const refs = new Set<string>();
+  collectRefs(node.props, refs);
+  collectRefs(node.on, refs);
+  if (isVizNode(node)) collectRefs(node.data?.queryContext, refs);
+  refs.forEach(name => {
+    if (!declared.has(name)) {
+      errors.push({
+        path,
+        message: `references undeclared variable "$${name}"`,
+      });
+    }
+  });
+
+  node.children?.forEach((child, i) =>
+    validateNode(child, `${path}.children[${i}]`, declared, errors),
+  );
+}
+
+export function validateCanvas(definition: CanvasDefinition): ValidationResult {
+  const errors: ValidationError[] = [];
+
+  if (typeof definition?.cdlVersion !== 'number') {
+    errors.push({
+      path: 'cdlVersion',
+      message: 'cdlVersion (number) is required',
+    });
+  }
+  if (!definition?.variables || typeof definition.variables !== 'object') {
+    errors.push({ path: 'variables', message: 'variables object is required' });
+  }
+  if (!definition?.tree) {
+    errors.push({ path: 'tree', message: 'tree (root node) is required' });
+    return { valid: false, errors };
+  }
+
+  const declared = new Set(Object.keys(definition.variables ?? {}));
+  validateNode(definition.tree, 'tree', declared, errors);
+
+  return { valid: errors.length === 0, errors };
+}
diff --git a/superset-frontend/src/pages/Canvas/index.tsx b/superset-frontend/src/pages/Canvas/index.tsx
new file mode 100644
index 00000000000..1d3a52711c0
--- /dev/null
+++ b/superset-frontend/src/pages/Canvas/index.tsx
@@ -0,0 +1,28 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { FC } from 'react';
+import { useParams } from 'react-router-dom';
+import { CanvasViewer } from 'src/Canvas/CanvasViewer';
+
+const CanvasRoute: FC = () => {
+  const { canvasId } = useParams<{ canvasId: string }>();
+  return <CanvasViewer idOrUuid={canvasId} />;
+};
+
+export default CanvasRoute;
diff --git a/superset-frontend/src/pages/CanvasList/index.tsx b/superset-frontend/src/pages/CanvasList/index.tsx
new file mode 100644
index 00000000000..41d6d83ec59
--- /dev/null
+++ b/superset-frontend/src/pages/CanvasList/index.tsx
@@ -0,0 +1,121 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useMemo } from 'react';
+import { Link } from 'react-router-dom';
+import { t } from '@apache-superset/core/translation';
+import withToasts from 'src/components/MessageToasts/withToasts';
+import SubMenu from 'src/features/home/SubMenu';
+import { useListViewResource } from 'src/views/CRUD/hooks';
+import {
+  ListView,
+  ListViewFilterOperator as FilterOperator,
+  type ListViewFilters,
+} from 'src/components';
+
+const PAGE_SIZE = 25;
+
+interface CanvasObject {
+  id: number;
+  name: string;
+  changed_on_delta_humanized?: string;
+  created_by?: { first_name: string; last_name: string };
+}
+
+interface CanvasListProps {
+  addDangerToast: (msg: string) => void;
+  addSuccessToast: (msg: string) => void;
+}
+
+function CanvasList({ addDangerToast, addSuccessToast }: CanvasListProps) {
+  const {
+    state: {
+      loading,
+      resourceCount: canvasCount,
+      resourceCollection: canvases,
+    },
+    fetchData,
+    refreshData,
+  } = useListViewResource<CanvasObject>(
+    'canvas',
+    t('Canvases'),
+    addDangerToast,
+  );
+
+  const columns = useMemo(
+    () => [
+      {
+        accessor: 'name',
+        Header: t('Name'),
+        Cell: ({ row: { original } }: { row: { original: CanvasObject } }) => (
+          <Link to={`/canvas/${original.id}/`}>{original.name}</Link>
+        ),
+      },
+      {
+        accessor: 'created_by',
+        Header: t('Created by'),
+        disableSortBy: true,
+        Cell: ({ row: { original } }: { row: { original: CanvasObject } }) =>
+          original.created_by
+            ? `${original.created_by.first_name} ${original.created_by.last_name}`
+            : '',
+      },
+      {
+        accessor: 'changed_on_delta_humanized',
+        Header: t('Last modified'),
+      },
+    ],
+    [],
+  );
+
+  const filters: ListViewFilters = useMemo(
+    () => [
+      {
+        Header: t('Name'),
+        key: 'search',
+        id: 'name',
+        input: 'search',
+        operator: FilterOperator.Contains,
+      },
+    ],
+    [],
+  );
+
+  return (
+    <>
+      <SubMenu name={t('Canvases')} />
+      <ListView<CanvasObject>
+        className="canvas-list-view"
+        columns={columns}
+        count={canvasCount}
+        data={canvases}
+        fetchData={fetchData}
+        refreshData={refreshData}
+        filters={filters}
+        initialSort={[{ id: 'changed_on_delta_humanized', desc: true }]}
+        loading={loading}
+        pageSize={PAGE_SIZE}
+        addDangerToast={addDangerToast}
+        addSuccessToast={addSuccessToast}
+      />
+    </>
+  );
+}
+
+export default withToasts(CanvasList);
diff --git a/superset-frontend/src/views/routePaths.ts b/superset-frontend/src/views/routePaths.ts
index 0295e0897e4..f4e1d235dbf 100644
--- a/superset-frontend/src/views/routePaths.ts
+++ b/superset-frontend/src/views/routePaths.ts
@@ -27,6 +27,8 @@ export const RoutePaths = {
   FILE_HANDLER: '/file-handler',
   DASHBOARD: '/dashboard/:idOrSlug/',
   DASHBOARD_LIST: '/dashboard/list/',
+  CANVAS: '/canvas/:canvasId/',
+  CANVAS_LIST: '/canvas/list/',
   CHART_ADD: '/chart/add',
   CHART_LIST: '/chart/list/',
   DATASET_LIST: '/tablemodelview/list/',
diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx
index 0e1901256fb..bb683cf4f00 100644
--- a/superset-frontend/src/views/routes.tsx
+++ b/superset-frontend/src/views/routes.tsx
@@ -80,6 +80,14 @@ const Dashboard = lazy(
   () => import(/* webpackChunkName: "Dashboard" */ 'src/pages/Dashboard'),
 );
 
+const Canvas = lazy(
+  () => import(/* webpackChunkName: "Canvas" */ 'src/pages/Canvas'),
+);
+
+const CanvasList = lazy(
+  () => import(/* webpackChunkName: "CanvasList" */ 'src/pages/CanvasList'),
+);
+
 const DatabaseList = lazy(
   () => import(/* webpackChunkName: "DatabaseList" */ 'src/pages/DatabaseList'),
 );
@@ -206,6 +214,10 @@ export const routes: Routes = [
   { path: RoutePaths.FILE_HANDLER, Component: FileHandler },
   { path: RoutePaths.DASHBOARD_LIST, Component: DashboardList },
   { path: RoutePaths.DASHBOARD, Component: Dashboard },
+  // CANVAS_LIST must precede CANVAS: '/canvas/:canvasId/' would otherwise
+  // match '/canvas/list/' with canvasId="list".
+  { path: RoutePaths.CANVAS_LIST, Component: CanvasList },
+  { path: RoutePaths.CANVAS, Component: Canvas },
   { path: RoutePaths.CHART_ADD, Component: ChartCreation },
   { path: RoutePaths.CHART_LIST, Component: ChartList },
   { path: RoutePaths.DATASET_LIST, Component: DatasetList },
diff --git a/superset/canvas/__init__.py b/superset/canvas/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/superset/canvas/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/superset/canvas/api.py b/superset/canvas/api.py
new file mode 100644
index 00000000000..f3d583fd1a8
--- /dev/null
+++ b/superset/canvas/api.py
@@ -0,0 +1,70 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+import logging
+
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+
+from superset.canvas.filters import CanvasAccessFilter
+from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
+from superset.models.canvas import Canvas
+from superset.views.base_api import BaseSupersetModelRestApi, RelatedFieldFilter
+from superset.views.filters import BaseFilterRelatedUsers, FilterRelatedUsers
+
+logger = logging.getLogger(__name__)
+
+
+class CanvasRestApi(BaseSupersetModelRestApi):
+    datamodel = SQLAInterface(Canvas)
+
+    include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {RouteMethod.RELATED}
+    class_permission_name = "Canvas"
+    method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
+
+    resource_name = "canvas"
+    allow_browser_login = True
+
+    base_filters = [["id", CanvasAccessFilter, lambda: []]]
+
+    show_columns = [
+        "id",
+        "name",
+        "definition",
+        "created_by.first_name",
+        "created_by.id",
+        "created_by.last_name",
+        "changed_on_delta_humanized",
+    ]
+    list_columns = [
+        "id",
+        "name",
+        "changed_on_delta_humanized",
+        "created_by.first_name",
+        "created_by.id",
+        "created_by.last_name",
+    ]
+    add_columns = ["name", "definition"]
+    edit_columns = add_columns
+    order_columns = ["name", "changed_on_delta_humanized"]
+
+    allowed_rel_fields = {"created_by", "changed_by"}
+    related_field_filters = {
+        "created_by": RelatedFieldFilter("first_name", FilterRelatedUsers),
+    }
+    base_related_field_filters = {
+        "created_by": [["id", BaseFilterRelatedUsers, lambda: []]],
+    }
+    openapi_spec_tag = "Canvas"
diff --git a/superset/canvas/filters.py b/superset/canvas/filters.py
new file mode 100644
index 00000000000..02da9ab2940
--- /dev/null
+++ b/superset/canvas/filters.py
@@ -0,0 +1,59 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from typing import Any
+
+from flask_appbuilder.models.sqla.filters import BaseFilter
+from sqlalchemy import false, or_
+from sqlalchemy.orm.query import Query
+
+from superset.extensions import db, security_manager
+from superset.models.canvas import Canvas
+from superset.utils.core import get_user_id
+
+
+class CanvasAccessFilter(BaseFilter):  # pylint: disable=too-few-public-methods
+    """Scope canvases to the current user's editor/viewer grants.
+
+    Mirrors the chart/dashboard editors-viewers pattern (see
+    ``superset/charts/filters.py``); admins are unfiltered.
+    """
+
+    def apply(self, query: Query, value: Any) -> Query:
+        if security_manager.is_admin():
+            return query
+
+        from superset.subjects.models import canvas_editors, canvas_viewers
+        from superset.subjects.utils import get_user_subject_ids_subquery
+
+        user_id = get_user_id()
+        if not user_id:
+            return query.filter(false())
+
+        subject_subquery = get_user_subject_ids_subquery(user_id)
+        editor_query = (
+            db.session.query(Canvas.id)
+            .join(canvas_editors, Canvas.id == canvas_editors.c.canvas_id)
+            .filter(canvas_editors.c.subject_id.in_(subject_subquery))
+        )
+        viewer_query = (
+            db.session.query(Canvas.id)
+            .join(canvas_viewers, Canvas.id == canvas_viewers.c.canvas_id)
+            .filter(canvas_viewers.c.subject_id.in_(subject_subquery))
+        )
+        return query.filter(
+            or_(Canvas.id.in_(editor_query), Canvas.id.in_(viewer_query))
+        )
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index 7cf9e49a0c8..8b9d87d42f8 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -164,6 +164,7 @@ class SupersetAppInitializer:  # pylint: disable=too-many-public-methods
         from superset.async_events.api import AsyncEventsRestApi
         from superset.available_domains.api import AvailableDomainsRestApi
         from superset.cachekeys.api import CacheRestApi
+        from superset.canvas.api import CanvasRestApi
         from superset.charts.api import ChartRestApi
         from superset.charts.data.api import ChartDataRestApi
         from superset.css_templates.api import CssTemplateRestApi
@@ -201,6 +202,7 @@ class SupersetAppInitializer:  # pylint: disable=too-many-public-methods
         from superset.views.all_entities import TaggedObjectsModelView
         from superset.views.annotations import AnnotationLayerView
         from superset.views.api import Api
+        from superset.views.canvas import CanvasModelView
         from superset.views.chart.views import SliceModelView
         from superset.views.core import Superset
         from superset.views.css_templates import CssTemplateModelView
@@ -256,6 +258,7 @@ class SupersetAppInitializer:  # pylint: disable=too-many-public-methods
         appbuilder.add_api(CacheRestApi)
         appbuilder.add_api(ChartRestApi)
         appbuilder.add_api(ChartDataRestApi)
+        appbuilder.add_api(CanvasRestApi)
         appbuilder.add_api(CssTemplateRestApi)
         appbuilder.add_api(ThemeRestApi)
         appbuilder.add_api(CurrentUserRestApi)
@@ -347,6 +350,14 @@ class SupersetAppInitializer:  # pylint: disable=too-many-public-methods
             category="",
             category_icon="",
         )
+        appbuilder.add_view(
+            CanvasModelView,
+            "Canvases",
+            label=_("Canvases"),
+            icon="fa-object-group",
+            category="",
+            category_icon="",
+        )
 
         appbuilder.add_link(
             "Datasets",
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index d92d6361d73..87d42c64b7e 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -752,6 +752,12 @@ from superset.mcp_service.annotation_layer.tool import (  # noqa: F401, E402
     list_annotation_layers,
     list_layer_annotations,
 )
+from superset.mcp_service.canvas.tool import (  # noqa: F401, E402
+    generate_canvas,
+    get_canvas,
+    get_canvas_schema,
+    update_canvas,
+)
 from superset.mcp_service.chart import (  # noqa: F401, E402
     prompts as chart_prompts,
     resources as chart_resources,
diff --git a/superset/mcp_service/canvas/__init__.py b/superset/mcp_service/canvas/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/superset/mcp_service/canvas/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/superset/mcp_service/canvas/patch.py b/superset/mcp_service/canvas/patch.py
new file mode 100644
index 00000000000..da864879a88
--- /dev/null
+++ b/superset/mcp_service/canvas/patch.py
@@ -0,0 +1,187 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Targeted edits to a CDL tree, addressed by stable node id.
+
+This is why nodes carry ids: an agent can restyle, move, replace or remove a
+single node instead of regenerating the whole canvas (which silently drifts the
+parts nobody asked to change).
+"""
+
+from __future__ import annotations
+
+import copy
+from collections.abc import Iterator
+from typing import Any
+
+Node = dict[str, Any]
+
+
+def _walk(node: Node, parent: Node | None = None) -> Iterator[tuple[Node, Node | None]]:
+    """Yield (node, parent) for the tree, depth-first."""
+    yield node, parent
+    for child in node.get("children") or []:
+        yield from _walk(child, node)
+
+
+def find_node(tree: Node, node_id: str) -> tuple[Node | None, Node | None]:
+    """Return (node, parent) for `node_id`; (None, None) when absent."""
+    for node, parent in _walk(tree):
+        if node.get("id") == node_id:
+            return node, parent
+    return None, None
+
+
+def _detach(tree: Node, node_id: str) -> Node | None:
+    node, parent = find_node(tree, node_id)
+    if node is None or parent is None:
+        return None
+    parent["children"] = [c for c in parent.get("children") or [] if c is not node]
+    return node
+
+
+def _insert_into(
+    parent: Node,
+    node: Node,
+    before: str | None,
+    after: str | None,
+    index: int | None,
+) -> None:
+    children = parent.setdefault("children", [])
+    position = len(children)
+    if index is not None:
+        position = max(0, min(index, len(children)))
+    elif before is not None:
+        position = next(
+            (i for i, c in enumerate(children) if c.get("id") == before), len(children)
+        )
+    elif after is not None:
+        position = next(
+            (i + 1 for i, c in enumerate(children) if c.get("id") == after),
+            len(children),
+        )
+    children.insert(position, node)
+
+
+def apply_ops(  # noqa: C901
+    definition: Node, ops: list[dict[str, Any]]
+) -> tuple[Node, list[str]]:
+    """Apply patch ops to a CDL definition.
+
+    Returns (new_definition, errors). The input is never mutated; on any error
+    the caller should discard the result rather than persist a partial edit.
+    """
+    updated = copy.deepcopy(definition)
+    tree = updated.get("tree")
+    errors: list[str] = []
+    if not isinstance(tree, dict):
+        return updated, ["definition.tree is missing or not an object"]
+
+    for i, op in enumerate(ops):
+        path = f"ops[{i}]"
+        kind = op.get("op")
+        node_id = op.get("id")
+        if not kind:
+            errors.append(f"{path}: 'op' is required")
+            continue
+        if not node_id:
+            errors.append(f"{path}: 'id' is required")
+            continue
+
+        target, parent = find_node(tree, node_id)
+
+        # `insert` addresses the PARENT, so it is the one op that tolerates a
+        # target that is not itself being edited.
+        if kind == "insert":
+            new_node = op.get("node")
+            if not isinstance(new_node, dict):
+                errors.append(f"{path}: 'node' object is required for insert")
+            elif target is None:
+                errors.append(f"{path}: no parent node with id {node_id!r}")
+            else:
+                _insert_into(
+                    target,
+                    new_node,
+                    op.get("before"),
+                    op.get("after"),
+                    op.get("index"),
+                )
+            continue
+
+        if target is None:
+            errors.append(f"{path}: no node with id {node_id!r}")
+            continue
+
+        if kind == "setStyle":
+            style = op.get("style") or {}
+            if op.get("merge", True):
+                target["style"] = {**(target.get("style") or {}), **style}
+            else:
+                target["style"] = dict(style)
+        elif kind == "setProps":
+            props = op.get("props") or {}
+            if op.get("merge", True):
+                target["props"] = {**(target.get("props") or {}), **props}
+            else:
+                target["props"] = dict(props)
+        elif kind == "setOption":
+            option = op.get("option") or {}
+            if op.get("merge", True):
+                target["option"] = {**(target.get("option") or {}), **option}
+            else:
+                target["option"] = dict(option)
+        elif kind == "remove":
+            if parent is None:
+                errors.append(f"{path}: cannot remove the root node")
+                continue
+            _detach(tree, node_id)
+        elif kind == "replace":
+            node = op.get("node")
+            if not isinstance(node, dict):
+                errors.append(f"{path}: 'node' object is required for replace")
+                continue
+            if parent is None:
+                updated["tree"] = node
+                tree = node
+                continue
+            children = parent.get("children") or []
+            parent["children"] = [node if c is target else c for c in children]
+        elif kind == "move":
+            if parent is None:
+                errors.append(f"{path}: cannot move the root node")
+                continue
+            new_parent_id = op.get("parent")
+            before, after, index = op.get("before"), op.get("after"), op.get("index")
+            if new_parent_id:
+                new_parent, _ = find_node(tree, new_parent_id)
+                if new_parent is None:
+                    errors.append(f"{path}: no parent node with id {new_parent_id!r}")
+                    continue
+            else:
+                # Default to reordering within the current parent.
+                new_parent = parent
+            if before is None and after is None and index is None:
+                errors.append(f"{path}: move needs one of 'before', 'after', 'index'")
+                continue
+            detached = _detach(tree, node_id)
+            if detached is None:
+                errors.append(f"{path}: could not detach {node_id!r}")
+                continue
+            _insert_into(new_parent, detached, before, after, index)
+        else:
+            errors.append(f"{path}: unknown op {kind!r}")
+
+    return updated, errors
diff --git a/superset/mcp_service/canvas/schemas.py b/superset/mcp_service/canvas/schemas.py
new file mode 100644
index 00000000000..767c875bba2
--- /dev/null
+++ b/superset/mcp_service/canvas/schemas.py
@@ -0,0 +1,139 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Pydantic request/response schemas for the canvas MCP tools."""
+
+from __future__ import annotations
+
+from typing import Any, Literal
+
+from pydantic import BaseModel, Field
+
+
+class GetCanvasSchemaRequest(BaseModel):
+    """No arguments — returns the static CDL contract."""
+
+
+class GetCanvasSchemaResponse(BaseModel):
+    cdl_schema: dict[str, Any] = Field(
+        ..., description="The CDL contract to author a canvas against."
+    )
+
+
+class GenerateCanvasRequest(BaseModel):
+    name: str = Field(..., description="Human-readable canvas title.")
+    definition: dict[str, Any] = Field(
+        ...,
+        description=(
+            "The full CDL tree ({cdlVersion, variables, tree}). "
+            "Call get_canvas_schema first for the contract."
+        ),
+    )
+
+
+class CanvasInfo(BaseModel):
+    id: int
+    name: str
+    url: str
+    uuid: str | None = None
+
+
+class GetCanvasRequest(BaseModel):
+    identifier: int | str = Field(..., description="Canvas id or uuid.")
+
+
+class GetCanvasResponse(BaseModel):
+    canvas: CanvasInfo | None = None
+    definition: dict[str, Any] | None = Field(
+        default=None, description="The stored CDL tree — read node ids from here."
+    )
+    error: str | None = None
+
+
+class CanvasPatchOp(BaseModel):
+    """A single targeted edit addressed by node id."""
+
+    op: Literal[
+        "setStyle", "setProps", "setOption", "move", "replace", "insert", "remove"
+    ] = Field(..., description="The edit to perform.")
+    id: str = Field(
+        ...,
+        description=(
+            "Target node id. For 'insert' this is the PARENT node to insert into."
+        ),
+    )
+    style: dict[str, Any] | None = Field(
+        default=None, description="setStyle: style object to apply."
+    )
+    props: dict[str, Any] | None = Field(
+        default=None, description="setProps: props to apply."
+    )
+    option: dict[str, Any] | None = Field(
+        default=None, description="setOption: echarts option to apply (Viz nodes)."
+    )
+    node: dict[str, Any] | None = Field(
+        default=None, description="replace/insert: the full node object."
+    )
+    parent: str | None = Field(
+        default=None,
+        description="move: id of the new parent (defaults to the current parent).",
+    )
+    before: str | None = Field(
+        default=None, description="move/insert: place before this sibling id."
+    )
+    after: str | None = Field(
+        default=None, description="move/insert: place after this sibling id."
+    )
+    index: int | None = Field(
+        default=None, description="move/insert: explicit position among children."
+    )
+    merge: bool = Field(
+        default=True,
+        description=(
+            "setStyle/setProps/setOption: merge into the existing object (True) "
+            "or replace it wholesale (False)."
+        ),
+    )
+
+
+class UpdateCanvasRequest(BaseModel):
+    identifier: int | str = Field(..., description="Canvas id or uuid.")
+    ops: list[CanvasPatchOp] = Field(
+        ..., description="Ordered patch operations, applied in sequence."
+    )
+    name: str | None = Field(default=None, description="Optionally rename the canvas.")
+
+
+class UpdateCanvasResponse(BaseModel):
+    canvas: CanvasInfo | None = None
+    canvas_url: str | None = None
+    applied_ops: int | None = None
+    error: str | None = None
+    validation_errors: list[str] | None = Field(
+        default=None,
+        description="Patch or CDL failures — nothing was saved; fix and retry.",
+    )
+
+
+class GenerateCanvasResponse(BaseModel):
+    canvas: CanvasInfo | None = None
+    canvas_url: str | None = None
+    error: str | None = None
+    validation_errors: list[str] | None = Field(
+        default=None,
+        description="CDL validation failures — fix these and retry.",
+    )
+    warnings: list[str] | None = None
diff --git a/superset/mcp_service/canvas/tool/__init__.py b/superset/mcp_service/canvas/tool/__init__.py
new file mode 100644
index 00000000000..10b60c22c62
--- /dev/null
+++ b/superset/mcp_service/canvas/tool/__init__.py
@@ -0,0 +1,28 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from superset.mcp_service.canvas.tool.generate_canvas import generate_canvas
+from superset.mcp_service.canvas.tool.get_canvas import get_canvas
+from superset.mcp_service.canvas.tool.get_canvas_schema import get_canvas_schema
+from superset.mcp_service.canvas.tool.update_canvas import update_canvas
+
+__all__ = [
+    "generate_canvas",
+    "get_canvas",
+    "get_canvas_schema",
+    "update_canvas",
+]
diff --git a/superset/mcp_service/canvas/tool/generate_canvas.py b/superset/mcp_service/canvas/tool/generate_canvas.py
new file mode 100644
index 00000000000..0d799e37a1f
--- /dev/null
+++ b/superset/mcp_service/canvas/tool/generate_canvas.py
@@ -0,0 +1,121 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""MCP tool: generate_canvas."""
+
+import logging
+
+from fastmcp import Context
+from flask import g
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.extensions import db, event_logger
+from superset.mcp_service.canvas.schemas import (
+    CanvasInfo,
+    GenerateCanvasRequest,
+    GenerateCanvasResponse,
+)
+from superset.mcp_service.canvas.validation import validate_cdl
+from superset.mcp_service.utils.url_utils import get_superset_base_url
+from superset.utils import json
+
+logger = logging.getLogger(__name__)
+
+
+@tool(
+    tags=["mutate"],
+    class_permission_name="Canvas",
+    annotations=ToolAnnotations(
+        title="Create canvas",
+        readOnlyHint=False,
+        destructiveHint=False,
+    ),
+)
+def generate_canvas(
+    request: GenerateCanvasRequest,
+    ctx: Context,  # noqa: ARG001
+) -> GenerateCanvasResponse:
+    """Create a NEW AI-native canvas (v2 dashboard) from a CDL definition.
+
+    Call get_canvas_schema first for the contract, and list_datasets /
+    get_dataset_info to resolve real datasetId/columns/metrics for bound
+    charts. The definition is validated server-side; on failure the response
+    carries ``validation_errors`` to fix and retry (no canvas is created).
+
+    Returns the canvas id and URL on success.
+    """
+    definition = request.definition
+
+    if validation_errors := validate_cdl(definition):
+        return GenerateCanvasResponse(
+            error="CDL validation failed; fix validation_errors and retry.",
+            validation_errors=validation_errors,
+        )
+
+    try:
+        # Imported lazily to avoid encrypted-column init before app setup,
+        # matching generate_dashboard.
+        from superset.extensions import security_manager
+        from superset.models.canvas import Canvas
+        from superset.subjects.utils import get_user_subject
+
+        with event_logger.log_context(action="mcp.generate_canvas.db_write"):
+            canvas = Canvas()
+            canvas.name = request.name
+            canvas.definition = json.dumps(definition)
+
+            # Re-query the user in this session (g.user may be bound to a
+            # torn-down session in the MCP context; see generate_dashboard).
+            current_user = (
+                db.session.query(security_manager.user_model)
+                .filter_by(id=g.user.id)
+                .first()
+            )
+            if current_user:
+                subject = get_user_subject(current_user.id)
+                if subject:
+                    canvas.editors = [subject]
+
+            db.session.add(canvas)
+            db.session.commit()  # pylint: disable=consider-using-transaction
+            try:
+                db.session.refresh(canvas)
+            except SQLAlchemyError:
+                logger.warning(
+                    "Canvas %s created but refresh failed", canvas.id, exc_info=True
+                )
+
+        canvas_url = f"{get_superset_base_url()}/canvas/{canvas.id}/"
+        logger.info("Created canvas %s", canvas.id)
+        return GenerateCanvasResponse(
+            canvas=CanvasInfo(
+                id=canvas.id,
+                name=canvas.name,
+                url=canvas_url,
+                uuid=str(canvas.uuid) if canvas.uuid else None,
+            ),
+            canvas_url=canvas_url,
+        )
+    except (SQLAlchemyError, ValueError, AttributeError) as ex:
+        try:
+            db.session.rollback()  # pylint: disable=consider-using-transaction
+        except SQLAlchemyError:
+            logger.warning("Rollback failed during error handling", exc_info=True)
+        logger.error("Error creating canvas: %s", ex, exc_info=True)
+        return GenerateCanvasResponse(
+            error="Failed to create canvas due to an internal error."
+        )
diff --git a/superset/mcp_service/canvas/tool/get_canvas.py b/superset/mcp_service/canvas/tool/get_canvas.py
new file mode 100644
index 00000000000..bb27ea33071
--- /dev/null
+++ b/superset/mcp_service/canvas/tool/get_canvas.py
@@ -0,0 +1,73 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""MCP tool: get_canvas."""
+
+import logging
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.mcp_service.canvas.schemas import (
+    CanvasInfo,
+    GetCanvasRequest,
+    GetCanvasResponse,
+)
+from superset.mcp_service.canvas.utils import find_canvas
+from superset.mcp_service.utils.url_utils import get_superset_base_url
+from superset.utils import json
+
+logger = logging.getLogger(__name__)
+
+
+@tool(
+    tags=["read"],
+    class_permission_name="Canvas",
+    annotations=ToolAnnotations(
+        title="Get canvas",
+        readOnlyHint=True,
+        destructiveHint=False,
+    ),
+)
+def get_canvas(
+    request: GetCanvasRequest,
+    ctx: Context,  # noqa: ARG001
+) -> GetCanvasResponse:
+    """Read a canvas and its CDL definition by id or uuid.
+
+    Call this before update_canvas so you can read the node ids you want to
+    target with patch operations.
+    """
+    try:
+        canvas = find_canvas(request.identifier)
+        if canvas is None:
+            return GetCanvasResponse(error=f"Canvas {request.identifier!r} not found.")
+        definition = json.loads(canvas.definition) if canvas.definition else None
+        return GetCanvasResponse(
+            canvas=CanvasInfo(
+                id=canvas.id,
+                name=canvas.name,
+                url=f"{get_superset_base_url()}/canvas/{canvas.id}/",
+                uuid=str(canvas.uuid) if canvas.uuid else None,
+            ),
+            definition=definition,
+        )
+    except (SQLAlchemyError, ValueError) as ex:
+        logger.error("Error reading canvas: %s", ex, exc_info=True)
+        return GetCanvasResponse(
+            error="Failed to read canvas due to an internal error."
+        )
diff --git a/superset/mcp_service/canvas/tool/get_canvas_schema.py b/superset/mcp_service/canvas/tool/get_canvas_schema.py
new file mode 100644
index 00000000000..6aa50362681
--- /dev/null
+++ b/superset/mcp_service/canvas/tool/get_canvas_schema.py
@@ -0,0 +1,55 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""MCP tool: get_canvas_schema."""
+
+import logging
+
+from fastmcp import Context
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.mcp_service.canvas.schemas import (
+    GetCanvasSchemaRequest,
+    GetCanvasSchemaResponse,
+)
+from superset.mcp_service.canvas.validation import build_cdl_schema
+
+logger = logging.getLogger(__name__)
+
+
+@tool(
+    tags=["read"],
+    class_permission_name="Canvas",
+    annotations=ToolAnnotations(
+        title="Get canvas schema",
+        readOnlyHint=True,
+        destructiveHint=False,
+    ),
+)
+def get_canvas_schema(
+    request: GetCanvasSchemaRequest,  # noqa: ARG001
+    ctx: Context,  # noqa: ARG001
+) -> GetCanvasSchemaResponse:
+    """Return the CDL contract to author an AI-native canvas (v2 dashboard).
+
+    Call this BEFORE composing a canvas so you emit a valid definition. It
+    lists node types, the action vocabulary, formatter kinds, hard rules
+    (no code, declarative formatters), and a worked example.
+
+    Workflow: get_canvas_schema -> list_datasets -> get_dataset_info (columns
+    and metrics) -> compose CDL -> generate_canvas.
+    """
+    return GetCanvasSchemaResponse(cdl_schema=build_cdl_schema())
diff --git a/superset/mcp_service/canvas/tool/update_canvas.py b/superset/mcp_service/canvas/tool/update_canvas.py
new file mode 100644
index 00000000000..e9744473aad
--- /dev/null
+++ b/superset/mcp_service/canvas/tool/update_canvas.py
@@ -0,0 +1,119 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""MCP tool: update_canvas."""
+
+import logging
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.extensions import db, event_logger
+from superset.mcp_service.canvas.patch import apply_ops
+from superset.mcp_service.canvas.schemas import (
+    CanvasInfo,
+    UpdateCanvasRequest,
+    UpdateCanvasResponse,
+)
+from superset.mcp_service.canvas.utils import find_canvas
+from superset.mcp_service.canvas.validation import validate_cdl
+from superset.mcp_service.utils.url_utils import get_superset_base_url
+from superset.utils import json
+
+logger = logging.getLogger(__name__)
+
+
+@tool(
+    tags=["mutate"],
+    class_permission_name="Canvas",
+    annotations=ToolAnnotations(
+        title="Update canvas",
+        readOnlyHint=False,
+        destructiveHint=False,
+    ),
+)
+def update_canvas(
+    request: UpdateCanvasRequest,
+    ctx: Context,  # noqa: ARG001
+) -> UpdateCanvasResponse:
+    """Edit an existing canvas with targeted patch ops instead of regenerating it.
+
+    Call get_canvas first to read node ids. Ops are applied in order:
+      - setStyle  {id, style}          restyle a node (merge by default)
+      - setProps  {id, props}          change component props (e.g. a title)
+      - setOption {id, option}         change a Viz node's echarts option
+      - move      {id, before|after|index, parent?}  reorder / reparent a node
+      - insert    {id: <parentId>, node, before|after|index}  add a node
+      - replace   {id, node}           swap a node wholesale
+      - remove    {id}                 delete a node
+
+    The patched CDL is fully re-validated; on any failure NOTHING is saved and
+    validation_errors explains why. Prefer this over generate_canvas for edits —
+    regenerating drifts the parts the user did not ask to change.
+    """
+    try:
+        canvas = find_canvas(request.identifier)
+        if canvas is None:
+            return UpdateCanvasResponse(
+                error=f"Canvas {request.identifier!r} not found."
+            )
+
+        definition = json.loads(canvas.definition) if canvas.definition else {}
+        ops = [op.model_dump(exclude_none=True) for op in request.ops]
+
+        updated, patch_errors = apply_ops(definition, ops)
+        if patch_errors:
+            return UpdateCanvasResponse(
+                error="Patch failed; nothing was saved.",
+                validation_errors=patch_errors,
+            )
+
+        cdl_errors = validate_cdl(updated)
+        if cdl_errors:
+            return UpdateCanvasResponse(
+                error="Patched canvas is not valid CDL; nothing was saved.",
+                validation_errors=cdl_errors,
+            )
+
+        with event_logger.log_context(action="mcp.update_canvas.db_write"):
+            canvas.definition = json.dumps(updated)
+            if request.name:
+                canvas.name = request.name
+            db.session.add(canvas)
+            db.session.commit()  # pylint: disable=consider-using-transaction
+
+        canvas_url = f"{get_superset_base_url()}/canvas/{canvas.id}/"
+        logger.info("Updated canvas %s with %s ops", canvas.id, len(ops))
+        return UpdateCanvasResponse(
+            canvas=CanvasInfo(
+                id=canvas.id,
+                name=canvas.name,
+                url=canvas_url,
+                uuid=str(canvas.uuid) if canvas.uuid else None,
+            ),
+            canvas_url=canvas_url,
+            applied_ops=len(ops),
+        )
+    except (SQLAlchemyError, ValueError, AttributeError) as ex:
+        try:
+            db.session.rollback()  # pylint: disable=consider-using-transaction
+        except SQLAlchemyError:
+            logger.warning("Rollback failed during error handling", exc_info=True)
+        logger.error("Error updating canvas: %s", ex, exc_info=True)
+        return UpdateCanvasResponse(
+            error="Failed to update canvas due to an internal error."
+        )
diff --git a/superset/mcp_service/canvas/utils.py b/superset/mcp_service/canvas/utils.py
new file mode 100644
index 00000000000..eafef83a2e5
--- /dev/null
+++ b/superset/mcp_service/canvas/utils.py
@@ -0,0 +1,33 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Shared helpers for the canvas MCP tools."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from superset.extensions import db
+
+
+def find_canvas(identifier: int | str) -> Any:
+    """Look up a Canvas by integer id or uuid string."""
+    from superset.models.canvas import Canvas
+
+    query = db.session.query(Canvas)
+    if isinstance(identifier, int) or str(identifier).isdigit():
+        return query.filter(Canvas.id == int(identifier)).one_or_none()
+    return query.filter(Canvas.uuid == str(identifier)).one_or_none()
diff --git a/superset/mcp_service/canvas/validation.py b/superset/mcp_service/canvas/validation.py
new file mode 100644
index 00000000000..bb25e21a3a4
--- /dev/null
+++ b/superset/mcp_service/canvas/validation.py
@@ -0,0 +1,734 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Server-side CDL validation.
+
+A faithful port of the frontend validator (superset-frontend/src/Canvas/
+validator.ts). It enforces structure, the no-code invariant, and variable
+reference integrity so that ``generate_canvas`` can reject bad AI output with
+structured errors the agent self-corrects against.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+# The catalog: the allowlist of node types plus their contract. Mirrors
+# superset-frontend/src/Canvas/catalog.ts.
+NODE_CATALOG: dict[str, dict[str, Any]] = {
+    "Column": {
+        "category": "layout",
+        "container": True,
+        "events": [],
+        "bindableProps": [],
+        "requiredProps": [],
+    },  # noqa: E501
+    "Row": {
+        "category": "layout",
+        "container": True,
+        "events": [],
+        "bindableProps": [],
+        "requiredProps": [],
+    },  # noqa: E501
+    "Card": {
+        "category": "layout",
+        "container": True,
+        "events": [],
+        "bindableProps": [],
+        "requiredProps": [],
+    },  # noqa: E501
+    "Tabs": {
+        "category": "layout",
+        "container": True,
+        "events": [],
+        "bindableProps": [],
+        "requiredProps": [],
+    },  # noqa: E501
+    "Board": {
+        "category": "layout",
+        "container": True,
+        "events": [],
+        "bindableProps": [],
+        "requiredProps": [],
+    },  # noqa: E501
+    "Tab": {
+        "category": "layout",
+        "container": True,
+        "events": [],
+        "bindableProps": [],
+        "requiredProps": ["label"],
+    },  # noqa: E501
+    "Divider": {
+        "category": "display",
+        "container": False,
+        "events": [],
+        "bindableProps": [],
+        "requiredProps": [],
+    },  # noqa: E501
+    "Alert": {
+        "category": "display",
+        "container": False,
+        "events": [],
+        "bindableProps": [],
+        "requiredProps": ["message"],
+    },  # noqa: E501
+    "Progress": {
+        "category": "display",
+        "container": False,
+        "events": [],
+        "bindableProps": ["value"],
+        "requiredProps": [],
+    },  # noqa: E501
+    "Collapse": {
+        "category": "layout",
+        "container": True,
+        "events": [],
+        "bindableProps": [],
+        "requiredProps": [],
+    },  # noqa: E501
+    "Modal": {
+        "category": "layout",
+        "container": True,
+        "events": [],
+        "bindableProps": [],
+        "requiredProps": [],
+    },  # noqa: E501
+    "Input": {
+        "category": "control",
+        "container": False,
+        "events": ["change"],
+        "bindableProps": ["value"],
+        "requiredProps": [],
+    },  # noqa: E501
+    "Switch": {
+        "category": "control",
+        "container": False,
+        "events": ["change"],
+        "bindableProps": ["value"],
+        "requiredProps": [],
+    },  # noqa: E501
+    "Select": {
+        "category": "control",
+        "container": False,
+        "events": ["change"],
+        "bindableProps": ["value"],
+        "requiredProps": ["options"],
+    },  # noqa: E501
+    "Button": {
+        "category": "control",
+        "container": False,
+        "events": ["click"],
+        "bindableProps": [],
+        "requiredProps": [],
+    },  # noqa: E501
+    "Filter": {
+        "category": "control",
+        "container": False,
+        "events": [],
+        "bindableProps": [],
+        "requiredProps": ["column"],
+    },  # noqa: E501
+    "Markdown": {
+        "category": "display",
+        "container": False,
+        "events": [],
+        "bindableProps": [],
+        "requiredProps": ["text"],
+    },  # noqa: E501
+    "Viz": {
+        "category": "viz",
+        "container": False,
+        "events": [],
+        "bindableProps": [],
+        "requiredProps": [],
+    },  # noqa: E501
+}
+
+ACTION_REQUIRED: dict[str, list[str]] = {
+    "setVariable": ["name", "value"],
+    "applyFilter": ["col", "op", "val"],
+    "crossFilter": ["col", "op", "val"],
+    "clearFilters": [],
+    "navigateTab": ["tabsId", "tab"],
+    "openModal": ["modalId"],
+    "closeModal": ["modalId"],
+    "openUrl": ["url"],
+    "refresh": [],
+}
+
+FORMATTER_KINDS = ["number", "currency", "percent", "date", "template"]
+
+# Declarative styling: an allowlisted CSS-property object (never a CSS string).
+# Values may be literals or "@themeToken" references resolved from the antd
+# theme at render time. Mirrors superset-frontend/src/Canvas/style.ts.
+STYLE_PROPERTIES = [
+    "padding",
+    "paddingTop",
+    "paddingRight",
+    "paddingBottom",
+    "paddingLeft",
+    "margin",
+    "marginTop",
+    "marginRight",
+    "marginBottom",
+    "marginLeft",
+    "gap",
+    "rowGap",
+    "columnGap",
+    "width",
+    "minWidth",
+    "maxWidth",
+    "height",
+    "minHeight",
+    "maxHeight",
+    "background",
+    "backgroundColor",
+    "color",
+    "border",
+    "borderColor",
+    "borderWidth",
+    "borderStyle",
+    "borderRadius",
+    "boxShadow",
+    "opacity",
+    "overflow",
+    "fontSize",
+    "fontWeight",
+    "fontFamily",
+    "lineHeight",
+    "letterSpacing",
+    "textAlign",
+    "textTransform",
+    "display",
+    "flex",
+    "flexDirection",
+    "flexWrap",
+    "alignItems",
+    "justifyContent",
+    "alignSelf",
+    "gridTemplateColumns",
+    "gridTemplateRows",
+    "gridColumn",
+    "gridRow",
+    "position",
+    "top",
+    "right",
+    "bottom",
+    "left",
+    "inset",
+    "zIndex",
+    "transform",
+    "transformOrigin",
+    "rotate",
+    "scale",
+    "translate",
+    "transition",
+    "aspectRatio",
+    "objectFit",
+    "filter",
+    "backdropFilter",
+    "mixBlendMode",
+    "cursor",
+    "pointerEvents",
+]
+
+_UNSAFE_STYLE_VALUE = re.compile(r"url\(|expression\(|javascript:|@import|<|/\*", re.I)
+
+_VAR_REF = re.compile(r"^\$([A-Za-z_]\w*)$")
+_CODE_SMELL = re.compile(r"=>|\bfunction\b|new\s+Function", re.IGNORECASE)
+_JS_URL = re.compile(r"^\s*(javascript|data|vbscript):", re.IGNORECASE)
+_EVENT_TOKEN = "$event"  # noqa: S105
+
+
+def _is_var_ref(value: Any) -> bool:
+    return isinstance(value, str) and bool(_VAR_REF.match(value))
+
+
+def _scan_no_code(value: Any, path: str, errors: list[str]) -> None:
+    """The core safety gate: reject executable-looking strings."""
+    if isinstance(value, str):
+        if _CODE_SMELL.search(value):
+            errors.append(f"{path}: disallowed executable string (no-code invariant)")
+        return
+    if isinstance(value, list):
+        for i, item in enumerate(value):
+            _scan_no_code(item, f"{path}[{i}]", errors)
+        return
+    if isinstance(value, dict):
+        for key, item in value.items():
+            if key == "formatter" and isinstance(item, str):
+                errors.append(
+                    f"{path}.formatter: formatter must be a declarative object, "
+                    "not a string"
+                )
+            _scan_no_code(item, f"{path}.{key}", errors)
+
+
+def _collect_refs(value: Any, out: set[str]) -> None:
+    if _is_var_ref(value) and value != _EVENT_TOKEN:
+        out.add(value[1:])
+    elif isinstance(value, list):
+        for item in value:
+            _collect_refs(item, out)
+    elif isinstance(value, dict):
+        for item in value.values():
+            _collect_refs(item, out)
+
+
+def _validate_style(style: Any, path: str, errors: list[str]) -> None:
+    if style is None:
+        return
+    if not isinstance(style, dict):
+        errors.append(f"{path}: style must be an object")
+        return
+    for prop, value in style.items():
+        if prop not in STYLE_PROPERTIES:
+            errors.append(f"{path}.{prop}: unsupported style property")
+            continue
+        if not isinstance(value, (str, int, float)) or isinstance(value, bool):
+            errors.append(f"{path}.{prop}: style values must be a string or number")
+            continue
+        if isinstance(value, str) and _UNSAFE_STYLE_VALUE.search(value):
+            errors.append(f"{path}.{prop}: disallowed value")
+
+
+def _validate_layout(layout: Any, path: str, errors: list[str]) -> None:
+    if layout is None:
+        return
+    if not isinstance(layout, dict):
+        errors.append(f"{path}: layout must be an object")
+        return
+    for key in ("x", "y", "w", "h"):
+        value = layout.get(key)
+        if not isinstance(value, (int, float)) or isinstance(value, bool):
+            errors.append(f"{path}.{key}: {key} must be a number")
+    for key in ("w", "h"):
+        value = layout.get(key)
+        if isinstance(value, (int, float)) and value < 1:
+            errors.append(f"{path}.{key}: {key} must be at least 1")
+
+
+def _validate_action(
+    action: Any, path: str, declared: set[str], errors: list[str]
+) -> None:
+    if not isinstance(action, dict):
+        errors.append(f"{path}: action must be an object")
+        return
+    name = action.get("action")
+    required = ACTION_REQUIRED.get(name) if isinstance(name, str) else None
+    if required is None:
+        errors.append(f"{path}: unknown action {name!r}")
+        return
+    for key in required:
+        if action.get(key) is None:
+            errors.append(f"{path}: action {name!r} missing {key!r}")
+    if name == "openUrl":
+        url = action.get("url")
+        if isinstance(url, str) and _JS_URL.match(url):
+            errors.append(f"{path}.url: openUrl allows http(s) only")
+    if name == "setVariable" and action.get("name") not in declared:
+        errors.append(f"{path}.name: undeclared variable {action.get('name')!r}")
+
+
+def _validate_node(  # noqa: C901
+    node: Any, path: str, declared: set[str], errors: list[str]
+) -> None:
+    if not isinstance(node, dict):
+        errors.append(f"{path}: node must be an object")
+        return
+    if not isinstance(node.get("id"), str) or not node.get("id"):
+        errors.append(f"{path}: node.id (string) is required")
+
+    node_type = node.get("type")
+    if node_type not in NODE_CATALOG:
+        errors.append(f"{path}: unknown node type {node_type!r} (not in catalog)")
+        return
+    entry = NODE_CATALOG[node_type]
+    props = node.get("props") or {}
+
+    for prop in entry["requiredProps"]:
+        if props.get(prop) is None:
+            errors.append(f"{path}.props.{prop}: required prop missing")
+
+    children = node.get("children") or []
+    if children and not entry["container"]:
+        errors.append(f"{path}.children: {node_type!r} cannot have children")
+
+    for prop, ref in (node.get("bind") or {}).items():
+        if prop not in entry["bindableProps"]:
+            errors.append(f"{path}.bind.{prop}: prop is not bindable")
+        elif not _is_var_ref(ref):
+            errors.append(f"{path}.bind.{prop}: must be a $var reference")
+        elif ref[1:] not in declared:
+            errors.append(f"{path}.bind.{prop}: undeclared variable {ref!r}")
+
+    for event, actions in (node.get("on") or {}).items():
+        if event not in entry["events"]:
+            errors.append(f"{path}.on.{event}: {node_type!r} does not emit {event!r}")
+        for i, action in enumerate(actions or []):
+            _validate_action(action, f"{path}.on.{event}[{i}]", declared, errors)
+
+    if node_type == "Viz":
+        renderer = node.get("renderer")
+        if renderer == "echarts":
+            data = node.get("data") or {}
+            if not data.get("queryContext"):
+                errors.append(f"{path}.data: echarts Viz requires data.queryContext")
+            if not data.get("encoding"):
+                errors.append(f"{path}.data: echarts Viz requires data.encoding")
+            _scan_no_code(node.get("option"), f"{path}.option", errors)
+        elif renderer == "supersetChart":
+            if not isinstance(node.get("chartId"), int):
+                errors.append(f"{path}.chartId: supersetChart Viz requires chartId")
+        else:
+            errors.append(f"{path}.renderer: unknown Viz renderer {renderer!r}")
+
+    _scan_no_code(props, f"{path}.props", errors)
+    _validate_style(node.get("style"), f"{path}.style", errors)
+    _validate_layout(node.get("layout"), f"{path}.layout", errors)
+
+    refs: set[str] = set()
+    _collect_refs(props, refs)
+    _collect_refs(node.get("on"), refs)
+    if node_type == "Viz":
+        _collect_refs((node.get("data") or {}).get("queryContext"), refs)
+    for name in refs:
+        if name not in declared:
+            errors.append(f"{path}: references undeclared variable ${name}")
+
+    for i, child in enumerate(children):
+        _validate_node(child, f"{path}.children[{i}]", declared, errors)
+
+
+def validate_cdl(definition: Any) -> list[str]:
+    """Return a list of human-readable validation errors (empty == valid)."""
+    errors: list[str] = []
+    if not isinstance(definition, dict):
+        return ["definition must be an object"]
+    if not isinstance(definition.get("cdlVersion"), int):
+        errors.append("cdlVersion (int) is required")
+    variables = definition.get("variables")
+    if not isinstance(variables, dict):
+        errors.append("variables object is required")
+        variables = {}
+    tree = definition.get("tree")
+    if not tree:
+        errors.append("tree (root node) is required")
+        return errors
+    declared = set(variables.keys())
+    _validate_node(tree, "tree", declared, errors)
+    return errors
+
+
+def build_cdl_schema() -> dict[str, Any]:
+    """The machine-readable CDL contract handed to the authoring agent."""
+    return {
+        "cdlVersion": 2,
+        "summary": (
+            "Canvas Definition Language: a typed, declarative component tree for "
+            "an AI-native dashboard. Compose layout, controls, and charts. Nothing "
+            "is code — presentation is a data 'option', data is a 'queryContext' + "
+            "'encoding', behaviour is a bounded 'action' enum."
+        ),
+        "envelope": {
+            "cdlVersion": "int (use 2)",
+            "variables": "map of name -> {type: string|number|boolean, default, scope: query|ui}",  # noqa: E501
+            "tree": "the root node",
+            "canvasWidth": (
+                "optional outer width cap. Omit or 'full' for full-bleed "
+                "(default, like a dashboard — use for boards and overviews); a "
+                "CSS width like '820px' centres a narrow reading column (use "
+                "for the narrative idiom)."
+            ),
+        },
+        "variableScopes": {
+            "query": "projected onto dataMask; drives bound queries (governed, RLS, cached)",  # noqa: E501
+            "ui": "client-only (active tab, toggles); never hits the backend",
+        },
+        "composition": {
+            "summary": (
+                "Do NOT default to a grid of chart cards with a filter bar on "
+                "top — that is just a dashboard, and Superset already builds "
+                "those. Choose the layout that fits the job, and put controls "
+                "next to the thing they control."
+            ),
+            "idioms": {
+                "narrative": (
+                    "A written brief. Set envelope canvasWidth:'820px' (or a "
+                    "root Column with style {maxWidth:'820px', margin:'0 auto'}); "
+                    "prose Markdown, charts as BARE Viz nodes "
+                    "between paragraphs (no Card), each followed by a small "
+                    "caption Markdown styled {color:'@colorTextSecondary', "
+                    "fontSize:'13px'}. Use when the canvas makes an argument."
+                ),
+                "tool": (
+                    "A parameterised instrument. Inline controls (see "
+                    "controlPlacement) followed by the charts they govern; "
+                    "split into sections so a later control only affects what "
+                    "comes after it."
+                ),
+                "bento": (
+                    "An overview. A Row with style {display:'grid', "
+                    "gridTemplateColumns:'repeat(12, minmax(0, 1fr))', gap:'20px'} "
+                    "and children given varied {gridColumn:'span N'} (and "
+                    "optionally {gridRow:'span 2'}). Vary the spans — a uniform "
+                    "grid reads as a dashboard."
+                ),
+                "sidebar": (
+                    "A Row containing a Column {flex:'0 0 300px'} rail and a "
+                    "Column {flex:'1 1 420px'} main area."
+                ),
+                "freeform": (
+                    "A Board node — coordinate placement, like a whiteboard or "
+                    "a report designer. Set props {columns:12, rowHeight:40, "
+                    "gap:8}; give each child a 'layout' {x,y,w,h,z?} in grid "
+                    "units (x,y = top-left cell, w,h = span). Children may "
+                    "OVERLAP — use 'z' to order them, e.g. a big-number tile "
+                    "floating over a faint background chart, or a callout "
+                    "pinned to a corner. Nothing else in Superset can do this."
+                ),
+            },
+            "controlPlacement": {
+                "rule": (
+                    "Prefer controls embedded where they are relevant over a "
+                    "control panel at the top. Spread multiple controls down "
+                    "the page, each above the charts it affects."
+                ),
+                "inlineSentenceRecipe": (
+                    "A Row styled {alignItems:'center', gap:'10px', "
+                    "flexWrap:'wrap', fontSize:'19px'} containing: Markdown "
+                    "fragment, the Input/Select, another Markdown fragment. "
+                    "Give each Markdown {flex:'0 0 auto', margin:'0'} and the "
+                    "input {flex:'0 0 110px'}, and drop the control's label — "
+                    "the sentence carries the meaning."
+                ),
+            },
+            "parametersVsFilters": (
+                "A Filter node picks values FROM a column — the same thing a "
+                "native dashboard filter does. A variable bound to an Input, "
+                "referenced in a filter with an operator "
+                "({col:'sales', op:'>', val:'$threshold'}), is a real "
+                "PARAMETER and has no dashboard equivalent. Reach for it "
+                "whenever the question is 'above/below X'. Declare the "
+                "variable as type 'number' so typed text is coerced."
+            ),
+            "gotchas": [
+                "Modal must be a child of the ROOT node, never of a grid — it "
+                "portals out of the DOM and would leave an empty grid cell.",
+                "Buttons/Switches inside a Row need {flex:'0 0 auto'}, or the "
+                "row stretches them to equal width.",
+                "Use 'repeat(12, minmax(0, 1fr))'; plain '1fr' lets a wide "
+                "chart blow out its track and force horizontal scrolling.",
+                "Panels sharing a grid band should have similar heights, or "
+                "set the grid {alignItems:'stretch'} and the cards "
+                "{height:'100%'}.",
+                "Any node that HOSTS a control (Filter/Select/Input) should "
+                "use '@colorBgContainer' for its background, not a hardcoded "
+                "colour — the control's label follows the theme and will be "
+                "invisible on a fixed surface in one of light/dark.",
+            ],
+        },
+        "styling": {
+            "summary": (
+                "Every node accepts an optional 'style' OBJECT (never a CSS "
+                "string) for layout and appearance. Values are literals "
+                "('16px', '1fr 1fr') or '@themeToken' references resolved from "
+                "the antd theme at render time — prefer tokens so light/dark "
+                "theming keeps working."
+            ),
+            "properties": STYLE_PROPERTIES,
+            "tokenExamples": [
+                "@colorPrimary",
+                "@colorBgContainer",
+                "@colorBorder",
+                "@colorText",
+                "@colorTextSecondary",
+                "@borderRadius",
+                "@boxShadow",
+                "@fontSizeLG",
+            ],
+            "example": {
+                "style": {
+                    "padding": "16px",
+                    "background": "@colorBgContainer",
+                    "borderRadius": "@borderRadius",
+                    "boxShadow": "@boxShadow",
+                }
+            },
+        },
+        "commonNodeFields": {
+            "id": "stable unique string",
+            "type": "one of nodeTypes",
+            "props": "typed props for the component",
+            "style": "optional allowlisted style object (see 'styling')",
+            "layout": "optional {x,y,w,h,z?} placement inside a Board parent",
+            "bind": "{prop: '$var'} two-way binding (only bindableProps)",
+            "on": "{event: [action, ...]} declarative handlers (only listed events)",
+            "children": "array of nodes (containers only)",
+        },
+        "nodeTypes": NODE_CATALOG,
+        "vizRenderers": {
+            "echarts": {
+                "data": {
+                    "queryContext": {
+                        "datasetId": "int (from list_datasets / get_dataset_info)",
+                        "metrics": "list of saved-metric names or SQL like 'SUM(sales)'",  # noqa: E501
+                        "groupby": "list of column names",
+                        "filters": "list of {col, op, val} — val may be '$var'",
+                        "rowLimit": "int — combine with orderby for top-N",
+                        "orderby": (
+                            "list of {by, desc} — 'by' names a metric or groupby "
+                            "column. Use [{by: 'SUM(sales)', desc: true}] with "
+                            "rowLimit for top-N, or [{by: 'year'}] to sort a "
+                            "time axis chronologically."
+                        ),
+                    },
+                    "encoding": {
+                        "x": "category column",
+                        "y": "value column or list",
+                        "series": "optional column that fans into one series each",
+                    },
+                },
+                "option": "an echarts option object (DATA ONLY — no functions)",
+            },
+            "supersetChart": {
+                "chartId": "int — an existing saved chart (use list_charts to find one)",  # noqa: E501
+                "filters": "optional extra [{col, op, val}] — val may be '$var'",
+                "notes": (
+                    "Renders the saved chart with its own viz plugin and "
+                    "form_data. Canvas Filter nodes on the same dataset are "
+                    "merged into its query automatically, so governed charts "
+                    "react to canvas filters. Prefer this over an echarts Viz "
+                    "when a suitable saved chart already exists."
+                ),
+            },
+        },
+        "filters": {
+            "summary": (
+                "A Filter node is a dashboard-style filter the user places. It "
+                "auto-populates its options from the column's distinct values and "
+                "auto-applies to EVERY echarts Viz on the same dataset — you do "
+                "NOT need to add it to each chart's queryContext.filters."
+            ),
+            "props": {
+                "column": "column to filter on (required)",
+                "dataset": "datasetId the filter applies to (match your Viz datasetId)",  # noqa: E501
+                "label": "display label",
+                "multiple": "true for a multi-select (IN) filter",
+                "op": "override the operator (default '==' single, 'IN' multiple)",
+                "options": "optional explicit [{value,label}]; omit to auto-fetch",
+            },
+            "example": {
+                "id": "f_platform",
+                "type": "Filter",
+                "props": {
+                    "column": "platform",
+                    "dataset": 1,
+                    "label": "Platform",
+                    "multiple": True,
+                },
+            },
+        },
+        "actions": {
+            name: {"required": required} for name, required in ACTION_REQUIRED.items()
+        },
+        "formatters": {
+            "kinds": FORMATTER_KINDS,
+            "shape": "{kind: 'currency', currency: 'USD'} etc — declarative, resolved to a function client-side",  # noqa: E501
+            "usage": "attach under option.yAxis.axisLabel.formatter or option.tooltip.valueFormatter",  # noqa: E501
+        },
+        "rules": [
+            "NEVER emit a function or code string anywhere (formatters, handlers, option).",  # noqa: E501
+            "A 'formatter' must be a declarative object, never a string.",
+            "openUrl allows http(s) only.",
+            "Every '$var' referenced must be declared in variables.",
+            "Only containers (Column, Row, Card, Tabs, Tab) may have children.",
+            "Tabs must contain Tab children (each needs a 'label'); the navigateTab "
+            "action switches them via {tabsId: <Tabs node id>, tab: <Tab node id>}.",
+            "Button actions are fully wired: setVariable, applyFilter/crossFilter "
+            "(write canvas filters), clearFilters (clears filters + resets variables), "
+            "navigateTab, refresh (re-runs every bound query).",
+            "Input and Switch two-way bind their 'value' to a $var, like Select.",
+            "Modal is hidden until a Button fires "
+            "{action:'openModal', modalId:<Modal id>} "
+            "— use it for drill-in detail panels without leaving the canvas.",
+            "Collapse children each become a panel titled by their props.label.",
+            "Alert (message + type info|success|warning|error) is for narrative "
+            "callouts; Progress binds 'value' to a $var for goal tracking.",
+            "Introspect datasets via list_datasets + get_dataset_info before building queryContext.",  # noqa: E501
+        ],
+        "example": {
+            "cdlVersion": 2,
+            "variables": {
+                "region": {"type": "string", "default": "APAC", "scope": "query"}
+            },  # noqa: E501
+            "tree": {
+                "id": "root",
+                "type": "Column",
+                "children": [
+                    {
+                        "id": "title",
+                        "type": "Markdown",
+                        "props": {"text": "Sales by month"},
+                    },  # noqa: E501
+                    {
+                        "id": "controls",
+                        "type": "Row",
+                        "children": [
+                            {
+                                "id": "region",
+                                "type": "Select",
+                                "props": {
+                                    "label": "Region",
+                                    "options": [
+                                        {"value": "APAC", "label": "APAC"},
+                                        {"value": "EMEA", "label": "EMEA"},
+                                    ],
+                                },
+                                "bind": {"value": "$region"},
+                            }
+                        ],
+                    },
+                    {
+                        "id": "chart",
+                        "type": "Viz",
+                        "renderer": "echarts",
+                        "data": {
+                            "queryContext": {
+                                "datasetId": 1,
+                                "metrics": ["SUM(sales)"],
+                                "groupby": ["month"],
+                                "filters": [
+                                    {"col": "region", "op": "==", "val": "$region"}
+                                ],  # noqa: E501
+                            },
+                            "encoding": {"x": "month", "y": "SUM(sales)"},
+                        },
+                        "option": {
+                            "series": [{"type": "line"}],
+                            "tooltip": {
+                                "valueFormatter": {
+                                    "kind": "currency",
+                                    "currency": "USD",
+                                }
+                            },
+                        },
+                    },
+                ],
+            },
+        },
+    }
diff --git a/superset/migrations/versions/2026-07-24_00-00_c4a1b2d3e5f6_add_canvas_tables.py b/superset/migrations/versions/2026-07-24_00-00_c4a1b2d3e5f6_add_canvas_tables.py
new file mode 100644
index 00000000000..1ae31b1176b
--- /dev/null
+++ b/superset/migrations/versions/2026-07-24_00-00_c4a1b2d3e5f6_add_canvas_tables.py
@@ -0,0 +1,78 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""add canvas tables
+
+Revision ID: c4a1b2d3e5f6
+Revises: e5f6a7b8c9d0
+Create Date: 2026-07-24 00:00:00.000000
+
+"""
+
+import sqlalchemy as sa
+from sqlalchemy_utils import UUIDType
+
+from superset.migrations.shared.utils import create_table, drop_table
+
+# revision identifiers, used by Alembic.
+revision = "c4a1b2d3e5f6"
+down_revision = "e5f6a7b8c9d0"
+
+
+def upgrade() -> None:
+    create_table(
+        "canvas",
+        sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+        sa.Column("uuid", UUIDType(binary=True), nullable=True),
+        sa.Column("name", sa.String(length=500), nullable=False),
+        sa.Column("definition", sa.Text(), nullable=True),
+        sa.Column("created_on", sa.DateTime(), nullable=True),
+        sa.Column("changed_on", sa.DateTime(), nullable=True),
+        sa.Column("created_by_fk", sa.Integer(), nullable=True),
+        sa.Column("changed_by_fk", sa.Integer(), nullable=True),
+        sa.ForeignKeyConstraint(["created_by_fk"], ["ab_user.id"]),
+        sa.ForeignKeyConstraint(["changed_by_fk"], ["ab_user.id"]),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("uuid"),
+    )
+
+    create_table(
+        "canvas_editors",
+        sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+        sa.Column("subject_id", sa.Integer(), nullable=False),
+        sa.Column("canvas_id", sa.Integer(), nullable=False),
+        sa.ForeignKeyConstraint(["subject_id"], ["subjects.id"], ondelete="CASCADE"),
+        sa.ForeignKeyConstraint(["canvas_id"], ["canvas.id"], ondelete="CASCADE"),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("subject_id", "canvas_id"),
+    )
+
+    create_table(
+        "canvas_viewers",
+        sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+        sa.Column("subject_id", sa.Integer(), nullable=False),
+        sa.Column("canvas_id", sa.Integer(), nullable=False),
+        sa.ForeignKeyConstraint(["subject_id"], ["subjects.id"], ondelete="CASCADE"),
+        sa.ForeignKeyConstraint(["canvas_id"], ["canvas.id"], ondelete="CASCADE"),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("subject_id", "canvas_id"),
+    )
+
+
+def downgrade() -> None:
+    drop_table("canvas_viewers")
+    drop_table("canvas_editors")
+    drop_table("canvas")
diff --git a/superset/models/canvas.py b/superset/models/canvas.py
new file mode 100644
index 00000000000..349ec6ca5d7
--- /dev/null
+++ b/superset/models/canvas.py
@@ -0,0 +1,48 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""A Canvas is a v2, AI-native dashboard.
+
+Unlike a v1 ``Dashboard`` (which stores a ``position_json`` layout referencing
+saved charts), a Canvas stores a single CDL (Canvas Definition Language) tree in
+``definition`` — a typed, declarative component tree an AI emits and the
+``CanvasRenderer`` walks. See superset-frontend/src/Canvas/DESIGN.md.
+"""
+
+from __future__ import annotations
+
+from flask_appbuilder import Model
+from sqlalchemy import Column, Integer, String
+from sqlalchemy.orm import relationship
+
+from superset.models.helpers import AuditMixinNullable, UUIDMixin
+from superset.subjects.models import canvas_editors, canvas_viewers, Subject
+from superset.utils.core import MediumText
+
+
+class Canvas(AuditMixinNullable, UUIDMixin, Model):
+    __tablename__ = "canvas"
+
+    id = Column(Integer, primary_key=True)
+    name = Column(String(500), nullable=False, default="Untitled canvas")
+    # The CDL definition, stored as a JSON string.
+    definition = Column(MediumText())
+
+    editors = relationship(Subject, secondary=canvas_editors, passive_deletes=True)
+    viewers = relationship(Subject, secondary=canvas_viewers, passive_deletes=True)
+
+    def __repr__(self) -> str:
+        return f"Canvas<{self.id} {self.name}>"
diff --git a/superset/subjects/models.py b/superset/subjects/models.py
index aaf173ba9b6..7ae2dbb34ac 100644
--- a/superset/subjects/models.py
+++ b/superset/subjects/models.py
@@ -201,3 +201,41 @@ report_schedule_editors = Table(
     ),
     UniqueConstraint("subject_id", "report_schedule_id"),
 )
+
+canvas_editors = Table(
+    "canvas_editors",
+    metadata,
+    Column("id", Integer, primary_key=True),
+    Column(
+        "subject_id",
+        Integer,
+        ForeignKey("subjects.id", ondelete="CASCADE"),
+        nullable=False,
+    ),
+    Column(
+        "canvas_id",
+        Integer,
+        ForeignKey("canvas.id", ondelete="CASCADE"),
+        nullable=False,
+    ),
+    UniqueConstraint("subject_id", "canvas_id"),
+)
+
+canvas_viewers = Table(
+    "canvas_viewers",
+    metadata,
+    Column("id", Integer, primary_key=True),
+    Column(
+        "subject_id",
+        Integer,
+        ForeignKey("subjects.id", ondelete="CASCADE"),
+        nullable=False,
+    ),
+    Column(
+        "canvas_id",
+        Integer,
+        ForeignKey("canvas.id", ondelete="CASCADE"),
+        nullable=False,
+    ),
+    UniqueConstraint("subject_id", "canvas_id"),
+)
diff --git a/superset/views/canvas.py b/superset/views/canvas.py
new file mode 100644
index 00000000000..947bfe230a3
--- /dev/null
+++ b/superset/views/canvas.py
@@ -0,0 +1,41 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from flask_appbuilder.api import expose
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+from flask_appbuilder.security.decorators import has_access
+
+from superset.constants import MODEL_VIEW_RW_METHOD_PERMISSION_MAP, RouteMethod
+from superset.models.canvas import Canvas
+from superset.superset_typing import FlaskResponse
+from superset.views.base import DeleteMixin, SupersetModelView
+
+
+class CanvasModelView(  # pylint: disable=too-many-ancestors
+    SupersetModelView,
+    DeleteMixin,
+):
+    route_base = "/canvas"
+    datamodel = SQLAInterface(Canvas)
+    include_route_methods = RouteMethod.LIST
+
+    class_permission_name = "Canvas"
+    method_permission_name = MODEL_VIEW_RW_METHOD_PERMISSION_MAP
+
+    @expose("/list/")
+    @has_access
+    def list(self) -> FlaskResponse:
+        return super().render_app_template()
diff --git a/superset/views/core.py b/superset/views/core.py
index 37d13f0a1c2..17e36a4d337 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -847,6 +847,31 @@ class Superset(BaseSupersetView):
             standalone_mode=ReservedUrlParameters.is_standalone_mode(),
         )
 
+    @has_access
+    @expose("/canvas/<int:pk>/")
+    def canvas(self, pk: int) -> FlaskResponse:
+        """Server-side entry that serves the SPA shell for a v2 canvas.
+
+        Object-level data access is enforced by ``CanvasRestApi`` (the viewer
+        fetches the definition through it); this view only gates the shell.
+        """
+        from superset.models.canvas import Canvas
+
+        canvas_obj = db.session.query(Canvas).filter(Canvas.id == pk).one_or_none()
+        if not canvas_obj:
+            if not get_current_user():
+                return redirect_to_login()
+            abort(404)
+
+        bootstrap_payload = {
+            "user": bootstrap_user_data(g.user, include_perms=True),
+            "common": common_bootstrap_payload(),
+        }
+        return self.render_app_template(
+            extra_bootstrap_data=bootstrap_payload,
+            title=canvas_obj.name,
+        )
+
     @has_access
     @expose("/dashboard/p/<key>/", methods=("GET",))
     def dashboard_permalink(
diff --git a/tests/unit_tests/mcp_service/canvas/__init__.py b/tests/unit_tests/mcp_service/canvas/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/tests/unit_tests/mcp_service/canvas/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/tests/unit_tests/mcp_service/canvas/test_canvas_patch.py b/tests/unit_tests/mcp_service/canvas/test_canvas_patch.py
new file mode 100644
index 00000000000..c744f2fba91
--- /dev/null
+++ b/tests/unit_tests/mcp_service/canvas/test_canvas_patch.py
@@ -0,0 +1,151 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Unit tests for targeted CDL patch operations."""
+
+from copy import deepcopy
+from typing import Any
+
+from superset.mcp_service.canvas.patch import apply_ops, find_node
+from superset.mcp_service.canvas.validation import validate_cdl
+
+
+def _definition() -> dict[str, Any]:
+    return {
+        "cdlVersion": 2,
+        "variables": {},
+        "tree": {
+            "id": "root",
+            "type": "Column",
+            "children": [
+                {
+                    "id": "a",
+                    "type": "Markdown",
+                    "props": {"text": "A"},
+                    "style": {"color": "#fff"},
+                },
+                {"id": "b", "type": "Markdown", "props": {"text": "B"}},
+                {"id": "c", "type": "Markdown", "props": {"text": "C"}},
+            ],
+        },
+    }
+
+
+def _order(definition: dict[str, Any]) -> list[str]:
+    return [child["id"] for child in definition["tree"]["children"]]
+
+
+def test_set_style_merges_by_default() -> None:
+    updated, errors = apply_ops(
+        _definition(),
+        [{"op": "setStyle", "id": "a", "style": {"background": "@colorBgContainer"}}],
+    )
+    assert errors == []
+    node, _ = find_node(updated["tree"], "a")
+    assert node is not None
+    assert node["style"] == {"color": "#fff", "background": "@colorBgContainer"}
+
+
+def test_set_style_replaces_when_merge_false() -> None:
+    updated, errors = apply_ops(
+        _definition(),
+        [
+            {
+                "op": "setStyle",
+                "id": "a",
+                "style": {"padding": "8px"},
+                "merge": False,
+            }
+        ],
+    )
+    assert errors == []
+    node, _ = find_node(updated["tree"], "a")
+    assert node is not None
+    assert node["style"] == {"padding": "8px"}
+
+
+def test_move_reorders_within_parent() -> None:
+    updated, errors = apply_ops(
+        _definition(), [{"op": "move", "id": "c", "before": "a"}]
+    )
+    assert errors == []
+    assert _order(updated) == ["c", "a", "b"]
+
+
+def test_remove_and_insert() -> None:
+    updated, errors = apply_ops(
+        _definition(),
+        [
+            {"op": "remove", "id": "b"},
+            {
+                "op": "insert",
+                "id": "root",
+                "node": {"id": "z", "type": "Divider"},
+                "after": "a",
+            },
+        ],
+    )
+    assert errors == []
+    assert _order(updated) == ["a", "z", "c"]
+
+
+def test_replace_swaps_a_node() -> None:
+    updated, errors = apply_ops(
+        _definition(),
+        [
+            {
+                "op": "replace",
+                "id": "b",
+                "node": {"id": "b", "type": "Markdown", "props": {"text": "new"}},
+            }
+        ],
+    )
+    assert errors == []
+    node, _ = find_node(updated["tree"], "b")
+    assert node is not None
+    assert node["props"]["text"] == "new"
+
+
+def test_input_definition_is_never_mutated() -> None:
+    definition = _definition()
+    snapshot = deepcopy(definition)
+    apply_ops(definition, [{"op": "move", "id": "c", "before": "a"}])
+    assert definition == snapshot
+
+
+def test_errors_for_unknown_id_and_op() -> None:
+    _, errors = apply_ops(
+        _definition(), [{"op": "setStyle", "id": "nope", "style": {}}]
+    )
+    assert any("no node with id" in e for e in errors)
+
+    _, errors = apply_ops(_definition(), [{"op": "bogus", "id": "a"}])
+    assert any("unknown op" in e for e in errors)
+
+
+def test_move_requires_a_target_position() -> None:
+    _, errors = apply_ops(_definition(), [{"op": "move", "id": "a"}])
+    assert any("needs one of" in e for e in errors)
+
+
+def test_root_cannot_be_removed() -> None:
+    _, errors = apply_ops(_definition(), [{"op": "remove", "id": "root"}])
+    assert any("cannot remove the root" in e for e in errors)
+
+
+def test_patched_tree_still_validates() -> None:
+    updated, _ = apply_ops(_definition(), [{"op": "move", "id": "c", "before": "a"}])
+    assert validate_cdl(updated) == []
diff --git a/tests/unit_tests/mcp_service/canvas/test_canvas_validation.py b/tests/unit_tests/mcp_service/canvas/test_canvas_validation.py
new file mode 100644
index 00000000000..17e40923819
--- /dev/null
+++ b/tests/unit_tests/mcp_service/canvas/test_canvas_validation.py
@@ -0,0 +1,81 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Unit tests for the server-side CDL validator."""
+
+from copy import deepcopy
+from typing import Any
+
+from superset.mcp_service.canvas.validation import build_cdl_schema, validate_cdl
+
+
+def _example() -> dict[str, Any]:
+    return deepcopy(build_cdl_schema()["example"])
+
+
+def test_schema_example_is_valid() -> None:
+    assert validate_cdl(_example()) == []
+
+
+def test_rejects_function_string_in_option() -> None:
+    definition = _example()
+    definition["tree"]["children"][2]["option"]["tooltip"] = {
+        "formatter": "(v) => v.toFixed(2)"
+    }
+    errors = validate_cdl(definition)
+    assert any("no-code invariant" in e for e in errors)
+    assert any("must be a declarative object" in e for e in errors)
+
+
+def test_rejects_undeclared_variable() -> None:
+    definition = _example()
+    definition["tree"]["children"][1]["children"][0]["bind"]["value"] = "$nope"
+    errors = validate_cdl(definition)
+    assert any("undeclared variable" in e for e in errors)
+
+
+def test_rejects_javascript_url_in_open_url() -> None:
+    definition = _example()
+    definition["tree"]["children"][0] = {
+        "id": "link",
+        "type": "Button",
+        "props": {"children": "Go"},
+        "on": {"click": [{"action": "openUrl", "url": "javascript:alert(1)"}]},
+    }
+    errors = validate_cdl(definition)
+    assert any("http(s) only" in e for e in errors)
+
+
+def test_rejects_children_on_non_container() -> None:
+    definition = _example()
+    definition["tree"]["children"][0]["children"] = [
+        {"id": "x", "type": "Markdown", "props": {"text": "no"}}
+    ]
+    errors = validate_cdl(definition)
+    assert any("cannot have children" in e for e in errors)
+
+
+def test_rejects_unknown_node_type() -> None:
+    definition = _example()
+    definition["tree"]["children"][0]["type"] = "NotAThing"
+    errors = validate_cdl(definition)
+    assert any("unknown node type" in e for e in errors)
+
+
+def test_missing_tree_is_reported() -> None:
+    assert validate_cdl({"cdlVersion": 2, "variables": {}}) == [
+        "tree (root node) is required"
+    ]