> ## Documentation Index
> Fetch the complete documentation index at: https://docs.xpertai.cn/llms.txt
> Use this file to discover all available pages before exploring further.

# Remote Component Plugins

> Build a plugin view with a React iframe remote component, ViewExtensionProvider, and secure host-side data bridge.

# Remote Component Plugins

This guide explains how to build a new `remote_component` plugin for the Assistant Workbench. `remote_component` is a View Extension view schema, not a trigger type: it can be listed from a normal view slot, or it can be opened explicitly by a tool result with `xpert.extension_view`. All data access and mutations still go through the platform's authenticated View Extension APIs.

Use this pattern when a plugin needs a full custom UI rather than a declarative table, list, stats, or detail view.

## Architecture

A remote component plugin has at least two pieces. Add a tool entry only when the view should be opened on demand by an Agent conversation:

1. **ViewExtensionProvider**: provides the manifest, data, parameter options, actions, and remote component HTML entry.
2. **Remote React page**: runs inside a sandboxed iframe and communicates with the host through `postMessage`.
3. **Optional tool entry**: returns `_meta['xpertai/visualization']` with `type: 'xpert.extension_view'` for tool-triggered scenarios.

The iframe is intentionally not trusted as an API client. It does not receive:

* access token
* API base URL
* `assistantId`
* `hostId`
* `hostType`
* permission information

The request path is:

```text theme={null}
remote iframe
  -> postMessage(requestData / requestParameterOptions / executeAction)
  -> data-xpert Workbench host
  -> authenticatedFetch(...)
  -> data-xpert API proxy
  -> xpert-pro ViewExtension API
  -> plugin ViewExtensionProvider
```

Authentication and authorization happen in data-xpert and xpert-pro:

* data-xpert Web attaches the current user's OIDC token through `authenticatedFetch`.
* data-xpert API verifies the current user and resolves `assistantCode` to the effective assistant.
* data-xpert API always forwards to `hostType=agent` and `hostId=<assistant.assistantId>`.
* xpert-pro checks view host access, manifest visibility, and action permissions before invoking the provider.

## Define Stable Keys

Use stable keys because they are part of the contract between the view host, Workbench canvas, optional tool result, and provider.

```ts theme={null}
export const PROVIDER_KEY = 'my_metric_management'
export const VIEW_KEY = 'metrics'
export const PUBLIC_VIEW_KEY = 'my_metric_management__metrics'
export const REMOTE_ENTRY_KEY = 'metric-management'
```

The public view key is:

```text theme={null}
<providerKey>__<manifestKey>
```

## Choose a Trigger Mode

`remote_component` can enter the host UI in two ways:

* **Slot-listed view**: the host calls `GET /api/view-hosts/:hostType/:hostId/slots/:slot/views`, receives the view manifest, and renders the remote component for that slot.
* **Tool-triggered view**: the tool result carries `_meta['xpertai/visualization']`, and the host opens the view only after that tool is used.

Use a slot-listed view when the plugin should appear as a regular extension entry for a host page. Use a tool-triggered view when the UI should appear only as the result of a specific tool call, such as "open metric management".

## Optional: Return a Tool-Triggered View

The tool should only open the view. It should not return the full page data.

```ts theme={null}
return {
  content: [
    {
      type: 'text',
      text: 'Opening metric management.'
    }
  ],
  _meta: {
    'xpertai/visualization': {
      type: 'xpert.extension_view',
      title: 'Metric Management',
      slotKey: 'tool:metric-management',
      parameterKey: `metrics:${projectId ?? 'all'}:${modelId ?? 'all'}`,
      renderMode: 'replace',
      payload: {
        version: 1,
        viewKey: 'my_metric_management__metrics',
        parameters: {
          projectId,
          modelId
        },
        initialQuery: {
          page: 1,
          pageSize: 20
        }
      },
      metadata: {
        source: 'mcp-tool',
        sourceId: 'metric_management_open'
      }
    }
  }
}
```

Do not put host identity, API URLs, tokens, or permissions in the tool result. The data-xpert backend derives the effective assistant host from the current user session.

## Provide a Remote Component Manifest

Register a `ViewExtensionProvider` and return a `remote_component + react + iframe` manifest from the `agent.workbench.main` slot.

```ts theme={null}
import {
  IXpertViewExtensionProvider,
  ViewExtensionProvider
} from '@xpert-ai/plugin-sdk'
import type {
  XpertExtensionViewManifest,
  XpertResolvedViewHostContext
} from '@xpert-ai/contracts'

@ViewExtensionProvider('my_metric_management')
export class MetricManagementViewProvider
  implements IXpertViewExtensionProvider
{
  supports(context: XpertResolvedViewHostContext) {
    return context.hostType === 'agent'
  }

  getViewManifests(
    _context: XpertResolvedViewHostContext,
    slot: string
  ): XpertExtensionViewManifest[] {
    if (slot !== 'agent.workbench.main') {
      return []
    }

    return [
      {
        key: 'metrics',
        title: { en_US: 'Metric Management', zh_Hans: '指标管理' },
        hostType: 'agent',
        slot,
        refreshable: true,
        parameters: [
          {
            key: 'projectId',
            label: { en_US: 'Project', zh_Hans: '项目' },
            required: true,
            type: 'string',
            optionSource: {
              mode: 'provider',
              searchable: true,
              preload: true
            }
          },
          {
            key: 'modelId',
            label: { en_US: 'Model', zh_Hans: '模型' },
            type: 'string',
            optionSource: {
              mode: 'provider',
              searchable: true,
              dependsOn: ['projectId']
            }
          }
        ],
        view: {
          type: 'remote_component',
          runtime: 'react',
          protocolVersion: 1,
          component: {
            isolation: 'iframe',
            entry: 'metric-management'
          },
          dataSource: {
            mode: 'platform'
          }
        },
        dataSource: {
          mode: 'platform',
          querySchema: {
            supportsPagination: true,
            supportsSearch: true,
            supportsSort: true,
            supportsParameters: true,
            defaultPageSize: 20
          },
          cache: {
            enabled: false
          }
        },
        actions: [
          {
            key: 'create',
            label: { en_US: 'Create', zh_Hans: '新建' },
            placement: 'toolbar',
            actionType: 'invoke'
          },
          {
            key: 'edit',
            label: { en_US: 'Edit', zh_Hans: '编辑' },
            placement: 'row',
            actionType: 'invoke',
            inputDefaults: 'target'
          }
        ]
      }
    ]
  }
}
```

Use action-level `permissions` when mutations require stronger permissions than reading the view.

## Return the Iframe HTML Entry

The provider returns a single HTML document for the iframe. Use `renderRemoteReactIframeHtml()` from `@xpert-ai/plugin-sdk` so all plugins share the same shell, reset, theme variables, and `.xui-*` UI classes.

```ts theme={null}
import { createRequire } from 'module'
import { readFile } from 'fs/promises'
import { dirname, join } from 'path'
import {
  IXpertViewExtensionProvider,
  renderRemoteReactIframeHtml
} from '@xpert-ai/plugin-sdk'
import type {
  XpertRemoteComponentEntry,
  XpertRemoteComponentViewSchema,
  XpertResolvedViewHostContext
} from '@xpert-ai/contracts'

const requireFromHere = createRequire(__filename)

async function readPackageFile(packageName: string, relativePath: string) {
  const packageRoot = dirname(
    requireFromHere.resolve(`${packageName}/package.json`)
  )
  return readFile(join(packageRoot, relativePath), 'utf8')
}

export class MetricManagementViewProvider
  implements IXpertViewExtensionProvider
{
  async getRemoteComponentEntry(
    _context: XpertResolvedViewHostContext,
    viewKey: string,
    component: XpertRemoteComponentViewSchema['component']
  ): Promise<XpertRemoteComponentEntry> {
    if (viewKey !== 'metrics' || component.entry !== 'metric-management') {
      return {
        html: '<!doctype html><html><body>Unsupported entry.</body></html>',
        contentType: 'text/html; charset=utf-8'
      }
    }

    const appScript = await readFile(
      join(__dirname, 'remote-components', 'metric-management', 'app.js'),
      'utf8'
    )
    const react = await readPackageFile('react', 'umd/react.production.min.js')
    const reactDom = await readPackageFile(
      'react-dom',
      'umd/react-dom.production.min.js'
    )

    return {
      html: renderRemoteReactIframeHtml({
        title: 'Metric Management',
        lang: 'en',
        reactUmd: react,
        reactDomUmd: reactDom,
        appScript
      }),
      contentType: 'text/html; charset=utf-8'
    }
  }
}
```

The `entry` value is a provider-local key, not a browser URL. The ViewExtension API rejects unsafe values such as absolute URLs, `..`, and backslashes.

## Implement the Remote Bridge Client

The iframe page sends requests to the parent window and waits for matching `requestId` responses.

```js theme={null}
(function () {
  const CHANNEL = 'xpertai.remote_component'
  const VERSION = 1
  let instanceId = null
  let sequence = 0
  const pending = new Map()

  function post(type, body) {
    if (!instanceId && type !== 'ready') return
    parent.postMessage(
      {
        channel: CHANNEL,
        protocolVersion: VERSION,
        instanceId,
        type,
        ...(body || {})
      },
      '*'
    )
  }

  function request(type, body) {
    const requestId = String(++sequence)
    post(type, { requestId, ...(body || {}) })
    return new Promise((resolve, reject) => {
      pending.set(requestId, { resolve, reject })
    })
  }

  window.addEventListener('message', (event) => {
    const message = event.data
    if (
      !message ||
      message.channel !== CHANNEL ||
      message.protocolVersion !== VERSION
    ) {
      return
    }

    if (message.type === 'init') {
      instanceId = message.instanceId
      window.startApp({
        manifest: message.manifest,
        payload: message.payload,
        initialQuery: message.initialQuery || {},
        locale: message.locale,
        theme: message.theme
      })
      return
    }

    if (message.instanceId !== instanceId) return

    if (message.requestId && pending.has(message.requestId)) {
      const item = pending.get(message.requestId)
      pending.delete(message.requestId)
      if (message.type === 'error') {
        item.reject(new Error(message.message || 'Remote request failed'))
      } else {
        item.resolve(message)
      }
    }
  })

  window.xpertRemote = { request }
  post('ready')
})()
```

The Workbench host only accepts messages from the iframe `contentWindow` and checks the `instanceId`.

## Example: Load the Metric List

The remote page does not call `fetch`. It asks the host for view data:

```js theme={null}
async function loadData(query) {
  const response = await window.xpertRemote.request('requestData', {
    query: {
      page: query.page,
      pageSize: query.pageSize,
      search: query.search,
      parameters: {
        projectId: query.parameters.projectId,
        modelId: query.parameters.modelId
      }
    }
  })

  setData(response.data || { items: [], total: 0 })
}
```

The host turns that into:

```http theme={null}
GET /agent-workbench/extensions/:assistantCode/views/:viewKey/data
  ?page=1
  &pageSize=20
  &search=revenue
  &parameters={"projectId":"project-1","modelId":"model-1"}
Authorization: Bearer <current-user-token>
```

data-xpert API resolves the assistant and forwards to xpert-pro:

```http theme={null}
GET /api/view-hosts/agent/:assistantId/views/:viewKey/data
Authorization: Bearer <current-user-token>
organization-id: <assistant-organization-id>
```

The provider handles the query:

```ts theme={null}
async getViewData(
  _context: XpertResolvedViewHostContext,
  viewKey: string,
  query: XpertViewQuery
): Promise<XpertViewDataResult> {
  if (viewKey !== 'metrics') {
    return {}
  }

  const projectId = getStringParameter(query.parameters, 'projectId')
  if (!projectId) {
    return {
      items: [],
      total: 0,
      meta: {
        reason: 'project_required'
      }
    }
  }

  const modelId = getStringParameter(query.parameters, 'modelId')
  const page = query.page ?? 1
  const pageSize = query.pageSize ?? 20

  const result = await this.indicatorService.findMy({
    where: buildIndicatorWhere(projectId, modelId, query.search),
    relations: ['model'],
    take: pageSize,
    skip: (page - 1) * pageSize,
    order: buildIndicatorOrder(query.sortBy, query.sortDirection)
  })

  return {
    items: result.items.map(toMetricRow),
    total: result.total
  }
}
```

The result is sent back to the iframe as:

```js theme={null}
{
  type: 'data',
  requestId,
  data: {
    items: [],
    total: 0
  }
}
```

## Example: Save One Metric

Create and edit can share one form. The remote page submits an action request:

```js theme={null}
async function saveMetric(mode, row, form, query) {
  const input = {
    code: form.code.trim(),
    name: form.name.trim(),
    type: form.type,
    modelId: form.modelId || undefined,
    entity: form.entity.trim() || undefined,
    business: form.business.trim() || undefined,
    unit: form.unit.trim() || undefined,
    visible: form.visible
  }

  const response = await window.xpertRemote.request('executeAction', {
    actionKey: mode === 'create' ? 'create' : 'edit',
    targetId: row && row.id,
    input,
    parameters: query.parameters
  })

  if (response.result?.refresh) {
    await loadData(query)
  }
}
```

The host turns the action into:

```http theme={null}
POST /agent-workbench/extensions/:assistantCode/views/:viewKey/actions/edit
Authorization: Bearer <current-user-token>
Content-Type: application/json
```

```json theme={null}
{
  "targetId": "metric-1",
  "input": {
    "code": "gmv",
    "name": "GMV",
    "type": "BASIC",
    "modelId": "model-1",
    "business": "Gross merchandise value",
    "unit": "USD",
    "visible": true
  },
  "parameters": {
    "projectId": "project-1",
    "modelId": "model-1"
  }
}
```

The provider performs the mutation:

```ts theme={null}
async executeViewAction(
  _context: XpertResolvedViewHostContext,
  viewKey: string,
  actionKey: string,
  request: XpertViewActionRequest
): Promise<XpertViewActionResult> {
  if (viewKey !== 'metrics') {
    return failure('Unsupported view', '不支持的视图')
  }

  if (actionKey === 'create') {
    const projectId = getStringParameter(request.parameters, 'projectId')
    if (!projectId) {
      return failure('Project is required', '请先选择项目')
    }

    await this.indicatorService.createDraft(
      toIndicatorDraft(request.input),
      projectId
    )
    return success('Metric created', '指标已创建')
  }

  const targetId = request.targetId?.trim()
  if (!targetId) {
    return failure('Target metric is required', '缺少目标指标')
  }

  if (actionKey === 'edit') {
    await this.indicatorService.updateDraft(
      targetId,
      toIndicatorDraft(request.input)
    )
    return success('Metric updated', '指标已更新')
  }

  return failure('Unsupported action', '不支持的操作')
}
```

Mutation actions should return `refresh: true` on success so the host can invalidate cache and the remote page can reload the current query.

```ts theme={null}
function success(en_US: string, zh_Hans: string): XpertViewActionResult {
  return {
    success: true,
    message: { en_US, zh_Hans },
    refresh: true
  }
}
```

## Parameter Options

Use `requestParameterOptions` for select boxes and dependent fields.

```js theme={null}
const projects = await window.xpertRemote.request('requestParameterOptions', {
  parameterKey: 'projectId',
  parameters: query.parameters
})

const models = await window.xpertRemote.request('requestParameterOptions', {
  parameterKey: 'modelId',
  parameters: {
    ...query.parameters,
    projectId
  }
})
```

Provider implementation:

```ts theme={null}
async getViewParameterOptions(
  _context: XpertResolvedViewHostContext,
  viewKey: string,
  parameterKey: string,
  query: XpertViewParameterOptionsQuery
): Promise<XpertViewParameterOptionsResult> {
  if (viewKey !== 'metrics') {
    return { items: [] }
  }

  if (parameterKey === 'projectId') {
    return {
      items: projects.map((project) => ({
        value: project.id,
        label: project.name ?? project.id
      }))
    }
  }

  if (parameterKey === 'modelId') {
    const projectId = getStringParameter(query.parameters, 'projectId')
    return {
      items: modelsFor(projectId).map((model) => ({
        value: model.id,
        label: model.name ?? model.id
      }))
    }
  }

  return { items: [] }
}
```

## Styling

Remote components should use the shared `.xui-*` classes provided by `renderRemoteReactIframeHtml()`:

* `.xui-app`
* `.xui-toolbar`
* `.xui-control`
* `.xui-input`
* `.xui-button`
* `.xui-button-primary`
* `.xui-table`
* `.xui-modal`
* `.xui-notice`

The host sends `init.theme.tokens`, and the helper maps them to `--xui-*` CSS variables. Plugin CSS should only add business layout and should not redefine the base control visuals.

## Build Assets

If the remote script is stored under the plugin source directory, make sure the package build copies it to the production output.

```json theme={null}
{
  "glob": "**/*.{html,css,js}",
  "input": "packages/analytics/src/plugins",
  "output": "src/plugins"
}
```

## Test Checklist

Before shipping, verify:

* the tool returns a valid `xpert.extension_view` payload
* the public view key matches `<providerKey>__<manifestKey>`
* the manifest returns `remote_component + react + iframe`
* `getRemoteComponentEntry` returns HTML without token, API URL, `assistantId`, or `hostId`
* list loading uses `requestData`
* saving uses `executeAction`
* parameter options work for both standalone and dependent fields
* unauthorized users cannot access the view host
* mutation actions have stricter permissions when needed
* the iframe works with `sandbox="allow-scripts"` and no direct network credentials
