diff --git a/AGENTS.md b/AGENTS.md
index b52ecc2571..018207a705 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -99,6 +99,10 @@ Before doing anything that requires a running NeMo platform (`nemo services`, `n
When working with the NeMo CLI (`nemo`), always check available skills first before exploring `--help`. Skills contain exact command syntax, JSON structures, and working examples that are much faster than trial-and-error discovery.
+## Building a Studio plugin web UI
+
+A plugin can ship a web UI that Studio loads at runtime and renders **inside its own React tree** — sharing Studio's React, router, and KUI design system rather than bundling its own. Before writing or reviewing plugin web code, read [plugins/example-plugin/web/AGENTS.md](plugins/example-plugin/web/AGENTS.md): it holds the contract (a `Root` component + `navItems`, externalized shared deps, KUI + theme tokens, auth rules) and is the canonical template to copy.
+
## Writing Python Code
- Don't put `__init__.py` files in packages. Instead prefer implicit namespace packages.
diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/interface.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/interface.py
index 8e35682316..abfbd198fb 100644
--- a/packages/nemo_platform_plugin/src/nemo_platform_plugin/interface.py
+++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/interface.py
@@ -6,6 +6,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
+from pathlib import Path
@dataclass
@@ -26,3 +27,23 @@ class PluginManifest:
name: str
version: str
description: str = field(default="")
+
+
+@dataclass
+class StudioSpec:
+ """Describes a plugin's Studio web UI contribution.
+
+ Registered under the ``nemo.studio`` entry-point group as a zero-argument
+ callable that returns an instance of this class.
+
+ Attributes:
+ name: Entry-point key matching the plugin name, e.g. ``"example"``.
+ bundle_path: Absolute path to the plugin's built ``web/dist/index.js``
+ on disk. Use ``Path(__file__).parent... / "web" / "dist" / "index.js"``
+ so the path resolves correctly for both editable and wheel installs.
+ ``None`` for plugins that have no web UI (Python-only plugins that
+ still wish to appear in the ``/apis/plugins`` manifest).
+ """
+
+ name: str
+ bundle_path: Path | None = field(default=None)
diff --git a/packages/nemo_platform_plugin/tests/test_interface.py b/packages/nemo_platform_plugin/tests/test_interface.py
new file mode 100644
index 0000000000..f1b593c9ad
--- /dev/null
+++ b/packages/nemo_platform_plugin/tests/test_interface.py
@@ -0,0 +1,24 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""Unit tests for nemo_platform_plugin.interface."""
+
+from __future__ import annotations
+
+import dataclasses
+from pathlib import Path
+
+from nemo_platform_plugin.interface import StudioSpec
+
+
+def test_studio_spec_stores_name_and_bundle_path():
+ path = Path("/some/web/dist/index.js")
+ spec = StudioSpec(name="example", bundle_path=path)
+ assert spec.name == "example"
+ assert spec.bundle_path == path
+
+
+def test_studio_spec_is_dataclass():
+ """StudioSpec should be a plain dataclass — no pydantic, no validation."""
+ assert dataclasses.is_dataclass(StudioSpec)
+ assert len(dataclasses.fields(StudioSpec)) == 2
diff --git a/packages/nmp_common/src/nmp/common/auth/middleware.py b/packages/nmp_common/src/nmp/common/auth/middleware.py
index f8b63a13a1..58a688a1f9 100644
--- a/packages/nmp_common/src/nmp/common/auth/middleware.py
+++ b/packages/nmp_common/src/nmp/common/auth/middleware.py
@@ -80,12 +80,14 @@ def _embedded_pdp_base_url_hint(config: AuthConfig) -> str:
# GET requests to these paths bypass authentication (e.g. / -> /studio redirect).
PUBLIC_GET_PATHS = {
"/",
+ "/apis/plugins", # Studio plugin manifest — fetched by the SPA before login completes
}
# Path prefixes that bypass authorization
BYPASS_PREFIXES = (
"/apis/auth/authenticate/", # Envoy ext_authz path_prefix callout includes the original protected path
"/studio", # Studio UI static files — the SPA handles its own OIDC login
+ "/plugin-ui/", # Studio plugin bundles — loaded via dynamic import(), cannot send Authorization
)
diff --git a/packages/nmp_common/tests/auth/test_middleware.py b/packages/nmp_common/tests/auth/test_middleware.py
index bd62f85146..6a83f4cc81 100644
--- a/packages/nmp_common/tests/auth/test_middleware.py
+++ b/packages/nmp_common/tests/auth/test_middleware.py
@@ -13,7 +13,7 @@
from nmp.common.auth.client import AuthClient
from nmp.common.auth.dependencies import get_auth_client
from nmp.common.auth.jwt import TokenClaims, UnsignedJWTRejectedError
-from nmp.common.auth.middleware import HEALTH_ENDPOINTS, PUBLIC_GET_PATHS, AuthorizationMiddleware
+from nmp.common.auth.middleware import BYPASS_PREFIXES, HEALTH_ENDPOINTS, PUBLIC_GET_PATHS, AuthorizationMiddleware
from nmp.common.auth.models import Principal
from nmp.common.auth.token_resolver import ResolvedBearerToken
from nmp.common.config import AuthConfig, Configuration
@@ -161,6 +161,62 @@ async def root_handler():
mock_authorize.assert_not_called()
+class TestStudioPluginBypass:
+ """Studio plugin manifest and bundles are public — the SPA fetches the manifest
+ anonymously and loads bundles via dynamic import(), which cannot send Authorization."""
+
+ def test_plugin_paths_in_bypass_lists(self):
+ assert "/apis/plugins" in PUBLIC_GET_PATHS
+ assert "/plugin-ui/" in BYPASS_PREFIXES
+
+ def test_plugins_manifest_get_bypasses_auth(self, auth_config_enabled):
+ app = FastAPI()
+
+ @app.get("/apis/plugins")
+ async def list_plugins():
+ return []
+
+ Configuration.set_override(auth_config_enabled)
+ app.add_middleware(AuthorizationMiddleware, service_name="test-service")
+
+ client = TestClient(app)
+ with patch("nmp.common.auth.client.AuthClient.authorize_request") as mock_authorize:
+ mock_authorize.return_value = MagicMock(allowed=False)
+ response = client.get("/apis/plugins")
+
+ assert response.status_code == 200
+ mock_authorize.assert_not_called()
+
+ def test_plugin_bundle_get_bypasses_auth(self, auth_config_enabled):
+ app = FastAPI()
+
+ @app.get("/plugin-ui/{plugin_name}/{filename}")
+ async def serve_bundle(plugin_name: str, filename: str):
+ return {"plugin": plugin_name, "filename": filename}
+
+ Configuration.set_override(auth_config_enabled)
+ app.add_middleware(AuthorizationMiddleware, service_name="test-service")
+
+ client = TestClient(app)
+ with patch("nmp.common.auth.client.AuthClient.authorize_request") as mock_authorize:
+ mock_authorize.return_value = MagicMock(allowed=False)
+ response = client.get("/plugin-ui/example/index.js")
+
+ assert response.status_code == 200
+ mock_authorize.assert_not_called()
+
+ def test_other_paths_still_require_auth(self, auth_config_enabled):
+ """The plugin bypasses must not open up other routes."""
+ app = create_test_app(auth_config_enabled)
+ client = TestClient(app, raise_server_exceptions=False)
+
+ with patch("nmp.common.auth.client.AuthClient.authorize_request") as mock_authorize:
+ mock_authorize.return_value = MagicMock(allowed=False)
+ response = client.get("/test")
+
+ assert response.status_code == 401
+
+
class TestBearerTokenAuth:
"""Tests for Bearer token authentication in middleware."""
diff --git a/plugins/example-plugin/pyproject.toml b/plugins/example-plugin/pyproject.toml
index d6533b3655..509f7d515a 100644
--- a/plugins/example-plugin/pyproject.toml
+++ b/plugins/example-plugin/pyproject.toml
@@ -38,6 +38,9 @@ example = "nemo_example_plugin.skills:get_skills_path"
[project.entry-points."nemo.inference_middleware"]
"nemo-example-middleware" = "nemo_example_plugin.middleware:ExampleInferenceMiddleware"
+[project.entry-points."nemo.studio"]
+example = "nemo_example_plugin.studio:get_studio_spec"
+
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
diff --git a/plugins/example-plugin/src/nemo_example_plugin/studio.py b/plugins/example-plugin/src/nemo_example_plugin/studio.py
new file mode 100644
index 0000000000..9324753d60
--- /dev/null
+++ b/plugins/example-plugin/src/nemo_example_plugin/studio.py
@@ -0,0 +1,20 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""Studio web UI registration for the example plugin."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from nemo_platform_plugin.interface import StudioSpec
+
+
+def get_studio_spec() -> StudioSpec:
+ """Return the StudioSpec for the example plugin's web UI.
+
+ Uses ``__file__`` so the path resolves correctly for both editable
+ (``uv pip install -e``) and wheel installs.
+ """
+ bundle_path = Path(__file__).parent / "web" / "dist" / "index.js"
+ return StudioSpec(name="example", bundle_path=bundle_path)
diff --git a/plugins/example-plugin/src/nemo_example_plugin/web/dist/index.js b/plugins/example-plugin/src/nemo_example_plugin/web/dist/index.js
new file mode 100644
index 0000000000..f04c97198d
--- /dev/null
+++ b/plugins/example-plugin/src/nemo_example_plugin/web/dist/index.js
@@ -0,0 +1,254 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+import { Button as e, Flex as t, Stack as n, Text as r } from "@nvidia/foundations-react-core";
+import { NavLink as i, Navigate as a, Outlet as o, Route as s, Routes as c } from "react-router-dom";
+import { jsx as l, jsxs as u } from "react/jsx-runtime";
+//#region src/Root.tsx
+function d({ host: e }) {
+ return /* @__PURE__ */ l(c, { children: /* @__PURE__ */ u(s, {
+ element: /* @__PURE__ */ l(f, {}),
+ children: [
+ /* @__PURE__ */ l(s, {
+ index: !0,
+ element: /* @__PURE__ */ l(a, {
+ to: "overview",
+ replace: !0
+ })
+ }),
+ /* @__PURE__ */ l(s, {
+ path: "overview",
+ element: /* @__PURE__ */ l(m, { host: e })
+ }),
+ /* @__PURE__ */ l(s, {
+ path: "auth",
+ element: /* @__PURE__ */ l(h, { getAccessToken: e.auth.getAccessToken })
+ }),
+ /* @__PURE__ */ l(s, {
+ path: "workspace",
+ element: /* @__PURE__ */ l(g, { workspaceId: e.workspaceId })
+ }),
+ /* @__PURE__ */ l(s, {
+ path: "*",
+ element: /* @__PURE__ */ l(_, {})
+ })
+ ]
+ }) });
+}
+function f() {
+ let e = ({ isActive: e }) => `px-3 py-1 rounded text-sm font-medium transition-colors ${e ? "text-primary bg-surface-hover" : "text-subtle hover:text-primary"}`;
+ return /* @__PURE__ */ u(n, {
+ gap: "4",
+ className: "h-full p-4",
+ children: [/* @__PURE__ */ u(t, {
+ gap: "2",
+ className: "border-b border-subtle pb-2",
+ children: [
+ /* @__PURE__ */ l(i, {
+ to: "overview",
+ className: e,
+ children: "Overview"
+ }),
+ /* @__PURE__ */ l(i, {
+ to: "auth",
+ className: e,
+ children: "Auth"
+ }),
+ /* @__PURE__ */ l(i, {
+ to: "workspace",
+ className: e,
+ children: "Workspace"
+ })
+ ]
+ }), /* @__PURE__ */ l("div", {
+ className: "flex-1",
+ children: /* @__PURE__ */ l(o, {})
+ })]
+ });
+}
+function p({ children: e }) {
+ return /* @__PURE__ */ l("pre", {
+ className: "bg-surface-sunken text-subtle rounded p-3 text-xs overflow-x-auto font-mono",
+ children: e
+ });
+}
+function m({ host: i }) {
+ let { data: a, isPending: o, isError: s } = i.sdk.platform.useEntitiesListWorkspaces({
+ page: 1,
+ page_size: 100
+ }, { query: { staleTime: 5e3 } }), c = a?.data ?? [];
+ return /* @__PURE__ */ u(n, {
+ gap: "2",
+ children: [
+ /* @__PURE__ */ l(r, {
+ kind: "label/bold/lg",
+ children: "Example Plugin"
+ }),
+ /* @__PURE__ */ l(r, {
+ kind: "body/regular/sm",
+ color: "secondary",
+ children: "This is an example Studio plugin. Use the tabs above or the Studio side nav to explore what information is available to a plugin at runtime."
+ }),
+ /* @__PURE__ */ u(n, {
+ gap: "1",
+ children: [
+ /* @__PURE__ */ l(r, {
+ kind: "label/bold/sm",
+ children: "Shared SDK"
+ }),
+ /* @__PURE__ */ l(r, {
+ kind: "body/regular/xs",
+ color: "secondary",
+ children: "Listed via Studio's sdk.platform.useEntitiesListWorkspaces() — the platform's typed hook, running on Studio's authenticated axios and shared QueryClient rather than a plugin copy."
+ }),
+ o ? /* @__PURE__ */ l(r, {
+ kind: "body/regular/xs",
+ color: "secondary",
+ children: "Loading…"
+ }) : s ? /* @__PURE__ */ l(r, {
+ kind: "body/regular/xs",
+ color: "danger",
+ children: "Request failed."
+ }) : /* @__PURE__ */ u(r, {
+ kind: "body/regular/sm",
+ children: [
+ c.length,
+ " workspaces: ",
+ c.map((e) => e.name).join(", ")
+ ]
+ })
+ ]
+ }),
+ /* @__PURE__ */ u(n, {
+ gap: "1",
+ children: [
+ /* @__PURE__ */ l(r, {
+ kind: "label/bold/sm",
+ children: "Host capabilities"
+ }),
+ /* @__PURE__ */ l(r, {
+ kind: "body/regular/xs",
+ color: "secondary",
+ children: "Studio's notifications, telemetry, and navigation, all off the host handle — no plugin-side setup."
+ }),
+ /* @__PURE__ */ u(t, {
+ gap: "2",
+ children: [/* @__PURE__ */ l(e, {
+ kind: "secondary",
+ onClick: () => {
+ i.notifications.notify("Toast from the example plugin", "success"), i.telemetry.event("overview_notify_clicked");
+ },
+ children: "Notify"
+ }), /* @__PURE__ */ l(e, {
+ kind: "secondary",
+ onClick: () => i.navigation.navigate(`/workspaces/${i.workspaceId}/base-models`),
+ children: "Go to Base Models"
+ })]
+ })
+ ]
+ })
+ ]
+ });
+}
+function h({ getAccessToken: e }) {
+ let t = e(), i = null;
+ try {
+ let e = t.split(".")[1];
+ e && (i = JSON.parse(atob(e.replace(/-/g, "+").replace(/_/g, "/"))));
+ } catch {}
+ return /* @__PURE__ */ u(n, {
+ gap: "3",
+ children: [
+ /* @__PURE__ */ l(r, {
+ kind: "label/bold/md",
+ children: "Auth"
+ }),
+ /* @__PURE__ */ l(r, {
+ kind: "body/regular/sm",
+ color: "secondary",
+ children: "Studio passes an OIDC access token to every plugin via the plugin's auth prop. Call getAccessToken() per request — it returns the current token after silent renewal — and use it as a Bearer token."
+ }),
+ /* @__PURE__ */ u(n, {
+ gap: "1",
+ children: [/* @__PURE__ */ l(r, {
+ kind: "label/bold/sm",
+ children: "Example API call"
+ }), /* @__PURE__ */ l(p, { children: "fetch('/apis/v1/workspaces', {\n headers: { Authorization: `Bearer ${getAccessToken()}` },\n})" })]
+ }),
+ /* @__PURE__ */ u(n, {
+ gap: "1",
+ children: [/* @__PURE__ */ l(r, {
+ kind: "label/bold/sm",
+ children: "Token claims (decoded, not verified)"
+ }), i ? /* @__PURE__ */ l(p, { children: JSON.stringify(i, null, 2) }) : /* @__PURE__ */ l(r, {
+ kind: "body/regular/xs",
+ color: "secondary",
+ children: t ? "Could not decode token." : "No token provided."
+ })]
+ })
+ ]
+ });
+}
+function g({ workspaceId: e }) {
+ return /* @__PURE__ */ u(n, {
+ gap: "3",
+ children: [
+ /* @__PURE__ */ l(r, {
+ kind: "label/bold/md",
+ children: "Workspace"
+ }),
+ /* @__PURE__ */ l(r, {
+ kind: "body/regular/sm",
+ color: "secondary",
+ children: "Studio passes the current workspace ID to every plugin via the plugin's workspaceId prop."
+ }),
+ /* @__PURE__ */ u(n, {
+ gap: "1",
+ children: [/* @__PURE__ */ l(r, {
+ kind: "label/bold/sm",
+ children: "Current workspace"
+ }), /* @__PURE__ */ l(p, { children: e })]
+ }),
+ /* @__PURE__ */ u(n, {
+ gap: "1",
+ children: [/* @__PURE__ */ l(r, {
+ kind: "label/bold/sm",
+ children: "Example API call scoped to this workspace"
+ }), /* @__PURE__ */ l(p, { children: "fetch(`/apis/v1/workspaces/${workspaceId}/models`, {\n headers: { Authorization: `Bearer ${getAccessToken()}` },\n})" })]
+ })
+ ]
+ });
+}
+function _() {
+ return /* @__PURE__ */ l(r, {
+ kind: "body/regular/sm",
+ color: "secondary",
+ children: "Page not found."
+ });
+}
+//#endregion
+//#region src/Nav.tsx
+var v = (e) => [{
+ group: "Example Plugin",
+ items: [
+ {
+ id: "example-overview",
+ iconName: "flask-conical",
+ label: "Overview",
+ href: `/workspaces/${e}/plugin/example/overview`
+ },
+ {
+ id: "example-auth",
+ iconName: "key-round",
+ label: "Auth",
+ href: `/workspaces/${e}/plugin/example/auth`
+ },
+ {
+ id: "example-workspace",
+ iconName: "building-2",
+ label: "Workspace",
+ href: `/workspaces/${e}/plugin/example/workspace`
+ }
+ ]
+}];
+//#endregion
+export { d as Root, v as navItems };
diff --git a/plugins/example-plugin/tests/test_studio.py b/plugins/example-plugin/tests/test_studio.py
new file mode 100644
index 0000000000..7921fdd760
--- /dev/null
+++ b/plugins/example-plugin/tests/test_studio.py
@@ -0,0 +1,19 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""Unit tests for nemo_example_plugin.studio."""
+
+from __future__ import annotations
+
+from nemo_example_plugin.studio import get_studio_spec
+from nemo_platform_plugin.interface import StudioSpec
+
+
+def test_get_studio_spec_returns_studio_spec():
+ spec = get_studio_spec()
+ assert isinstance(spec, StudioSpec)
+ assert spec.name == "example"
+ assert spec.bundle_path is not None
+ assert spec.bundle_path.name == "index.js"
+ assert "web" in spec.bundle_path.parts
+ assert "dist" in spec.bundle_path.parts
diff --git a/plugins/example-plugin/web/.gitignore b/plugins/example-plugin/web/.gitignore
new file mode 100644
index 0000000000..c2658d7d1b
--- /dev/null
+++ b/plugins/example-plugin/web/.gitignore
@@ -0,0 +1 @@
+node_modules/
diff --git a/plugins/example-plugin/web/AGENTS.md b/plugins/example-plugin/web/AGENTS.md
new file mode 100644
index 0000000000..435df8061a
--- /dev/null
+++ b/plugins/example-plugin/web/AGENTS.md
@@ -0,0 +1,104 @@
+# Studio plugin web UI — agent instructions
+
+This is the web UI for a NeMo Studio plugin. Studio loads the built bundle at
+runtime and renders it **inside its own React tree**. This dir is also the
+canonical template other plugins copy — keep it exemplary.
+
+Runtime contract: `../../../web/packages/studio/src/plugins/types.ts`.
+
+## Mental model
+
+- **One root, not two.** Export a `Root` **component**; Studio renders ``
+ under its own Router / QueryClient / KaizenThemeProvider. Never call
+ `createRoot`, never create a `BrowserRouter`.
+- **Share the singletons that carry context** — React, react-dom, react-router,
+ and `@nvidia/foundations-react-core` resolve via Studio's import map. This dir
+ **externalizes** them so the plugin uses Studio's one instance (shared router +
+ theme). Everything else bundles privately.
+
+## Rules — DO / DON'T
+
+| Concern | DO | DON'T |
+| --- | --- | --- |
+| Entry | `export { Root }` (component) + `export { navItems }` from `src/index.ts` | export `mount()` or call `createRoot` |
+| Routing | Studio's shared router — `Routes`/`Route`/`NavLink`/`Navigate`/`Outlet`/`useNavigate`, paths relative to the plugin mount | `BrowserRouter`, `history.pushState` patching, hardcoded `basename` |
+| Components | KUI from `@nvidia/foundations-react-core` — `Text`, `Stack`, `Flex`, `Button` | hand-rolled styled `
`s or native `