Skip to content

Hosted Plugin UI with TSX

If your plugin needs a visible UI in the Plugin Manager, start here.

Hosted UI is the recommended path for new plugin panels and guide pages. You keep the backend in Python, then describe the frontend as either:

  • Hosted TSX for interactive panels.
  • Markdown for simple read-only docs.

You do not need to build a separate frontend bundle. TSX is loaded from the plugin directory and compiled by the Plugin Manager at runtime.

Current recommendation

Use Hosted UI when your plugin needs:

  • a settings or management panel
  • buttons that call plugin entries
  • tables, forms, filters, and status cards
  • a quickstart or guide page
  • plugin-local i18n

Keep static UI only when you need a fully custom legacy page or you already have a standalone HTML/CSS/JS UI.

Choose the right surface

NeedRecommended mode
Interactive settings or management panelHosted TSX
Tool/server dashboardHosted TSX
Read-only guide or documentationMarkdown
Fully custom legacy pageStatic UI

Hosted TSX is the preferred mode for new interactive plugin UI. Static UI remains available for compatibility.

Minimal example layout

text
plugin/plugins/my_plugin/
  plugin.toml
  __init__.py
  ui/panel.tsx
  docs/quickstart.md
  i18n/en.json
  i18n/zh-CN.json

1. Declare surfaces in plugin.toml

toml
# Default plugin metadata. Required for every plugin, not specific to Hosted UI.
[plugin]
id = "my_plugin"
name = "My Plugin"
description = "A plugin with a hosted UI"
version = "0.1.0"
entry = "plugin.plugins.my_plugin:MyPlugin"

# Recommended when UI text should be translated. Keep "en" as the baseline.
[plugin.i18n]
default_locale = "en"
locales_dir = "i18n"

# Hosted UI switch. Required only when this plugin exposes surfaces.
[plugin.ui]
enabled = true

# Interactive panel. Required when the plugin needs buttons, forms, or tables.
[[plugin.ui.panel]]
id = "main"
title = "My Plugin"
# Required: .tsx selects Hosted TSX mode.
entry = "ui/panel.tsx"
# Required when the panel reads Python state. Must match @ui.context(id=...).
context = "dashboard"
# Required for action buttons. Remove action:call for read-only panels.
# Add config:read if the panel needs props.config.
permissions = ["state:read", "action:call"]

# Optional guide page. Use Markdown when the page is just documentation.
[[plugin.ui.guide]]
id = "quickstart"
title = "Quickstart"
# Required: .md selects Markdown mode.
entry = "docs/quickstart.md"
permissions = ["state:read"]

What these fields mean

FieldMeaning
panel / guide / docsWhere the surface appears in the Plugin Manager
idSurface identifier, unique within its kind
titleDisplay title
entryFile path relative to the plugin directory
contextPython @ui.context(id=...) provider used by this surface
permissionsSurface capabilities, such as state:read, config:read, and action:call

Mode is inferred from the entry extension:

ExtensionMode
.tsx, .jsxhosted-tsx
.md, .mdxmarkdown
.html, .htmstatic

2. Provide context and actions in Python

python
from plugin.sdk.plugin import (
    NekoPluginBase,  # Default plugin base class.
    neko_plugin,     # Default decorator for plugin discovery.
    plugin_entry,    # Default backend entry and LLM-visible tool.
    ui,              # Hosted UI decorators: context and action.
    tr,              # Recommended: plugin-local i18n reference.
    Ok,              # Recommended result helper for successful entries.
)


# Required for a normal Python plugin.
@neko_plugin
class MyPlugin(NekoPluginBase):
    # Hosted UI: required when a surface needs props.state.
    # The id must match plugin.toml: context = "dashboard".
    @ui.context(id="dashboard")
    async def dashboard(self):
        # This object becomes props.state in the TSX panel.
        return {
            "items": [
                {"id": "demo", "status": "ready"},
            ],
        }

    # Hosted UI: expose this plugin entry to the current surface.
    # Recommended: use tr(...) so the same label can be translated in i18n/*.json.
    @ui.action(
        label=tr("actions.refresh.label", default="Refresh"),
        tone="primary",
        # Recommended for state-changing actions: refresh props.state after success.
        refresh_context=True,
    )
    # Required for a callable backend entry. Hosted UI calls this entry.
    @plugin_entry(
        id="refresh_item",
        name=tr("entries.refresh.name", default="Refresh Item"),
        description=tr("entries.refresh.description", default="Refresh an item."),
        # Recommended: schema drives forms, validation hints, and LLM tool metadata.
        input_schema={
            "type": "object",
            "properties": {
                "item_id": {
                    "type": "string",
                    "description": tr("fields.itemId", default="Item ID"),
                },
            },
            "required": ["item_id"],
        },
        # Optional: tells the LLM-facing layer which result fields matter.
        llm_result_fields=["message"],
    )
    async def refresh_item(self, item_id: str, **_):
        return Ok({"message": f"Refreshed {item_id}"})

This gives the UI two things:

  • @ui.context(id="dashboard") returns the props.state payload.
  • @ui.action(...) exposes a backend entry as a UI action.
  • @plugin_entry(...) is still the callable backend entry and the LLM-visible tool metadata.
  • tr(...) declares a plugin-local i18n key with an English default.
  • refresh_context=True asks the hosted UI to refresh context after the action succeeds.

3. Build a TSX panel

tsx
// Hosted UI only: import components, hooks, and types from @neko/plugin-ui.
// Do not import npm packages from a plugin TSX file.
import {
  Page,
  Card,
  Stack,
  Text,
  DataTable,
  ActionButton,
} from "@neko/plugin-ui"
import type { HostedAction, PluginSurfaceProps } from "@neko/plugin-ui"

// Recommended: type the Python context payload for safer TSX.
type Item = {
  id: string
  status: string
}

type State = {
  items?: Item[]
}

// Required: Hosted TSX must export a default function component.
export default function Panel(props: PluginSurfaceProps<State>) {
  // Provided by Hosted UI:
  // - t: plugin-local translator
  // - state: result from @ui.context(...)
  // - actions: entries exposed by @ui.action(...)
  const { t, state, actions } = props

  // Recommended: locate actions by id instead of hardcoding labels in TSX.
  const refresh = actions.find((action) => action.id === "refresh_item") as HostedAction | undefined

  return (
    <Page title={props.plugin.name} subtitle={t("panel.subtitle")}>
      <Card title={t("panel.items")}>
        <Stack>
          {/* Recommended UI Kit component for simple tabular state. */}
          <DataTable
            data={state.items || []}
            rowKey="id"
            columns={[
              { key: "id", label: t("fields.itemId") },
              { key: "status", label: t("fields.status") },
            ]}
          />

          {/* Recommended shortcut. It calls the entry and refreshes context when
              the action has refresh_context=true. */}
          {refresh ? (
            <ActionButton action={refresh} values={{ item_id: "demo" }}>
              {t("actions.refresh.label")}
            </ActionButton>
          ) : (
            <Text>{t("panel.noActions")}</Text>
          )}
        </Stack>
      </Card>
    </Page>
  )
}

Hosted TSX is compiled online. Keep imports simple:

  • Use @neko/plugin-ui for components, hooks, and types.
  • Do not import npm packages from plugin TSX.
  • Keep business logic in Python; use TSX for UI state and interaction.

4. Add plugin i18n files

i18n/en.json:

json
{
  "panel.subtitle": "Manage plugin items.",
  "panel.items": "Items",
  "panel.noActions": "No actions exposed.",
  "actions.refresh.label": "Refresh",
  "entries.refresh.name": "Refresh Item",
  "entries.refresh.description": "Refresh an item.",
  "fields.itemId": "Item ID",
  "fields.status": "Status"
}

i18n/zh-CN.json:

json
{
  "panel.subtitle": "管理插件项目。",
  "panel.items": "项目",
  "panel.noActions": "没有暴露可用动作。",
  "actions.refresh.label": "刷新",
  "entries.refresh.name": "刷新项目",
  "entries.refresh.description": "刷新一个项目。",
  "fields.itemId": "项目 ID",
  "fields.status": "状态"
}

Use the same keys from Python and TSX:

python
# Python declarations: use tr(...) in decorators and schemas.
tr("actions.refresh.label", default="Refresh")

# Python runtime: useful for messages produced by plugin code.
self.i18n.t("messages.done", default="Done")
tsx
// TSX runtime: use props.t(...) for visible UI text.
props.t("panel.subtitle")
props.t("item.count", { count: 3 })

Fallback order:

  1. current locale
  2. base locale, such as zh from zh-CN
  3. plugin default_locale
  4. the default argument or key name

Only Chinese locales fall back to zh-CN; non-Chinese locales do not leak Chinese text by default.

5. Add a Markdown guide if needed

For a read-only guide, use a Markdown file:

toml
[[plugin.ui.guide]]
id = "quickstart"
title = "Quickstart"
# .md selects the simple Markdown renderer. No Python context is required
# unless this guide needs state from @ui.context(...).
entry = "docs/quickstart.md"
permissions = ["state:read"]

Supported Markdown features:

  • headings
  • paragraphs
  • unordered lists
  • blockquotes
  • fenced code blocks
  • inline code
  • http / https links

Not supported:

  • inline HTML
  • scripts
  • MDX components

API quick reference: PluginSurfaceProps

PropTypeDescription
pluginRecord<string, any>Plugin metadata
surfaceRecord<string, any>Current surface metadata
stategeneric StateContext state from Python
stateSchemaJsonSchema | nullOptional schema for state
actionsHostedAction[]Actions exposed by @ui.action
entriesRecord<string, any>[]Plugin entries
config{ schema, value, readonly }Read-only plugin config snapshot when config:read is allowed
warningsArray<{ path, code, message }>Surface warnings
localestringCurrent UI locale
t(key, params?) => stringPlugin-local translator
apiHostedApiAction and refresh bridge
useLocalStatehookiframe-local state persisted across context refresh

API quick reference: HostedApi

ts
type HostedApi = {
  call(actionId: string, args?: Record<string, any>): Promise<any>
  refresh(): Promise<any>
}
  • api.call() calls a plugin entry exposed by @ui.action.
  • api.refresh() fetches the latest context and re-renders the surface.
  • If an action has refresh_context=false, it will not refresh automatically.

UI Kit quick reference

Layout

ComponentPurpose
Pagepage shell
Cardsection card
Sectiongeneric section
Headingheading text
Stackvertical layout
Gridgrid layout
Textparagraph text
Dividerseparator

Data display

ComponentPurpose
StatusBadgestatus label
StatCardmetric card
KeyValuekey-value rows
DataTabletable
Listlist
JsonViewJSON preview
CodeBlockcode block

Forms and actions

ComponentPurpose
Fieldlabel/help/error wrapper
Inputtext input
Textareamultiline input
Selectselect input
Switchcheckbox switch
Formform wrapper
ActionFormschema-driven action form
ActionButtonbutton that calls an exposed action
RefreshButtonbutton that calls api.refresh()

Feedback and dialogs

ComponentPurpose
Alertinline message
InlineErrorerror block
EmptyStateempty placeholder
Modalmodal dialog
ConfirmDialogconfirm dialog
AsyncBlockasync loading/error/data block
Tipinformational tip
Warningwarning tip

Hooks quick reference

HookUse
useLocalStatesurface-local state that survives context refresh
useAsyncasync data with loading/error/reload
useFormform value helpers
useToasttoast notifications
useConfirmpromise-based confirm dialog
useDebouncedebounced derived value
useDebouncedStatestate plus debounced state
useI18ntranslator and current locale
useState, useEffect, useMemo, useCallback, useRef, useReducerbasic hosted runtime hooks

Example:

tsx
// Recommended for extra data that is loaded after initial render.
const tools = useAsync(() => props.api.call("list_tools"), [])

if (tools.loading) return <Text>Loading...</Text>
if (tools.error) return <InlineError error={tools.error} />

return <DataTable data={tools.data?.tools || []} />

Runtime limits

Hosted TSX is not full React. It intentionally supports a smaller runtime:

Supported:

  • function components
  • Fragment
  • keyed children
  • controlled inputs
  • hooks listed above
  • plugin-local i18n
  • action bridge

Not supported:

  • class components
  • React Context
  • portals
  • Suspense or concurrent rendering
  • server components
  • npm package imports from plugin TSX
  • dangerouslySetInnerHTML

useLayoutEffect currently behaves like useEffect; do not rely on pre-paint layout timing.

Testing

Run the full hosted UI check:

bash
# From the repository root: runs type checks, TSX checks, hosted tests,
# browser E2E, Python compile checks, and relevant pytest cases.
scripts/check-hosted-ui.sh

Useful subcommands:

bash
# Frontend-only checks.
cd frontend/plugin-manager
npm run check-hosted-tsx -- plugin/plugins/my_plugin
npm run test:hosted
npm run test:hosted:e2e

check-hosted-tsx verifies TSX syntax and types. The hosted tests cover the runtime, iframe execution, i18n coverage, and MCP Adapter panel fixture.

Complete example

See the MCP Adapter:

text
plugin/plugins/mcp_adapter/
  __init__.py
  plugin.toml
  ui/panel.tsx
  docs/quickstart.tsx
  i18n/en.json
  i18n/zh-CN.json

It demonstrates:

  • context state from Python
  • exposed actions
  • table and form UI
  • batch JSON import
  • toast and confirm dialog
  • plugin-local i18n
  • hosted TSX tests

Released under the MIT License.