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
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:
- ViewExtensionProvider: provides the manifest, data, parameter options, actions, and remote component HTML entry.
- Remote React page: runs inside a sandboxed iframe and communicates with the host through
postMessage.
- 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:
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.
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:
<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”.
The tool should only open the view. It should not return the full page data.
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.
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.
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.
(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:
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:
GET /agent-workbench/extensions/:assistantCode/views/:viewKey/data
?page=1
&pageSize=20
&search=revenue
¶meters={"projectId":"project-1","modelId":"model-1"}
Authorization: Bearer <current-user-token>
data-xpert API resolves the assistant and forwards to xpert-pro:
GET /api/view-hosts/agent/:assistantId/views/:viewKey/data
Authorization: Bearer <current-user-token>
organization-id: <assistant-organization-id>
The provider handles the query:
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:
{
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:
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:
POST /agent-workbench/extensions/:assistantCode/views/:viewKey/actions/edit
Authorization: Bearer <current-user-token>
Content-Type: application/json
{
"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:
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.
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.
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:
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.
{
"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