Skip to content

xopc Extension System

xopc provides a lightweight but powerful extension system for customizing and extending functionality.

Features

  • Three-tier storage — workspace-only, user-global, or bundled with the install (see below).
  • Activation from config — extensions load when you enable them, configure related channels or providers, or satisfy rules declared in each extension’s xopc.extension.json (see When extensions load).
  • Extension SDK@xopcai/xopc/extension-sdk (and optional subpaths such as extension-sdk/core, extension-sdk/lazy).
  • TypeScript — extensions are plain TypeScript/JavaScript modules; no separate compile step for the loader.
  • Install sources — npm package, local folder, or xopc-store package via xopc extensions install.
  • Gateway console UI (optional) — a ui block in the manifest can add panels in the Web console; iframe code uses @xopcai/extension-ui-sdk (see Gateway console: Extension UI).

Quick Start

Install Extension

Using CLI (recommended):

bash
# Install from npm (~/.xopc/extensions)
xopc extensions install xopc-extension-hello

# Install from local directory
xopc extensions install ./my-local-extension

# View installed extensions
xopc extensions list

# Check extension health
xopc extensions health

Enable Extension

Configure in ~/.xopc/xopc.json:

json
{
  "extensions": {
    "enabled": ["hello", "echo"],
    "hello": { "greeting": "Hi there!" },
    "echo": true
  }
}

Configuration format:

FieldTypeDescription
enabledstring[]List of extension IDs to enable
disabledstring[](Optional) List of extension IDs to disable
[extension-id]object | booleanExtension-specific configuration

Activation vs enabled: The gateway and xopc agent load extensions using the rules in When extensions load. You do not have to list every channel extension under extensions.enabled if activation is already triggered by channels.telegram / channels.weixin, or by an env var named in the extension manifest (for example TELEGRAM_BOT_TOKEN). To force-disable an extension, add its id to extensions.disabled.

Develop Extension Locally

bash
xopc extensions dev ./my-extension
xopc extensions pack ./my-extension

A valid extension project includes:

  • package.json - npm config
  • index.ts or index.js - extension entry
  • xopc.extension.json - extension manifest
  • README.md - documentation

Three-tier Storage Architecture

xopc supports three-tier extension storage:

LevelPathUse CasePriority
Workspaceworkspace/.extensions/Project-private extensions⭐⭐⭐ Highest
Global~/.xopc/extensions/User-level shared extensions⭐⭐ Medium
BundledInside the xopc installExtensions that ship with your package⭐ Lowest

Priority Rules

  • Workspace overrides Global and Bundled when the same extension id appears in more than one place.
  • Global overrides Bundled.

Typical use: project-specific extensions under the workspace; shared extensions under ~/.xopc/extensions/; built-in behaviour from the install.


When extensions load

The runtime first reads each discovered extension’s xopc.extension.json, then decides which ids to load from your config and environment. You do not always need every channel-related extension listed under extensions.enabled—for example, configuring channels.telegram or channels.weixin is often enough for the matching channel extension to load.

Rough priority (highest wins first):

  1. extensions.enabled / extensions.disabled — explicit lists; avoid putting the same id in both.
  2. Default agent model — if it matches optional modelSupport patterns on the manifest.
  3. Environment variables — names listed under providerAuthEnvVars or channelEnvVars on the manifest when they are set.
  4. autoEnableWhenConfiguredProviders — when your providers block matches declared provider ids.
  5. activation.onProviders / activation.onChannels — when configured providers or channels match.
  6. enabledByDefault: true on the manifest.

Where this applies

  • Gateway and xopc agent evaluate the full plan when they start extensions.
  • Other CLI commands may skip loading extensions unless extensions.enabled is non-empty, you have channel (or similar) configuration that implies an extension, or a manifest-indexed env var is set—so simple commands stay fast.

Optional manifest fields (xopc.extension.json)

All are optional. Common fields:

FieldPurpose
enabledByDefaultTurn on when nothing above overrides
providers, channelsLogical ids this extension implements
providerAuthEnvVars, channelEnvVarsMap ids to env var names for detection
providerAuthChoicesHints for UI/CLI auth flows
modelSupportmodelPrefixes, modelPatterns for model-based activation
autoEnableWhenConfiguredProvidersAuto-enable when listed providers appear in config
activationonProviders, onChannels, onCommands, onCapabilities
contracts, setupCapability and setup hints

Extension SDK

The npm package name is @xopcai/xopc. Import the SDK through the published subpath:

typescript
// Recommended: published package subpath
import type { ExtensionApi, ExtensionDefinition } from '@xopcai/xopc/extension-sdk';

Exported Types

typescript
// Core types
import type {
  ExtensionDefinition,      // Extension definition
  ExtensionApi,             // Extension API
  ExtensionLogger,          // Logger interface
} from '@xopcai/xopc/extension-sdk';

// Tools (re-exported from pi-agent-core)
import type {
  AgentTool,
  AgentToolResult,
} from '@xopcai/xopc/extension-sdk';

// Hooks
import type {
  ExtensionHookEvent,       // Hook event type
  ExtensionHookHandler,     // Hook handler
  HookOptions,              // Hook options
} from '@xopcai/xopc/extension-sdk';

// Channels (ChannelPlugin registry)
import type {
  ChannelPlugin,
  ChannelPluginInitOptions,
  ChannelPluginStartOptions,
} from '@xopcai/xopc/extension-sdk';

import {
  defineChannelPluginEntry,
  registerExtensionCliProgram,
} from '@xopcai/xopc/extension-sdk';

// Optional subpaths (smaller surface / tree-shaking), e.g.:
// import type { ExtensionApi } from '@xopcai/xopc/extension-sdk/core';
// import { lazyModule } from '@xopcai/xopc/extension-sdk/lazy';

// Commands
import type { ExtensionCommand } from '@xopcai/xopc/extension-sdk';

// Services
import type { ExtensionService } from '@xopcai/xopc/extension-sdk';

Gateway console: Extension UI (iframe)

Extensions can ship a Gateway Web console front-end that runs inside sandboxed iframes in the React app (web/). This is orthogonal to the Node Extension SDK above: iframe code does not call register() on the gateway; it uses postMessage, wrapped by @xopcai/extension-ui-sdk.

manifest.ui (optional)

FieldRole
mainDefault panel entry (path relative to the extension package)
iconIcon asset path
permissionsStrings gating SDK calls on the host (theme, agent.send, agent.subscribe, storage, …)
contributionspages, settingsPanels, chatWidgets, commands — Apps routes, settings sidebar, chat widgets, and ⌘/Ctrl+K command palette entries

If ui is absent, the extension may still be a backend-only extension (tools, hooks, channels).

Package: @xopcai/extension-ui-sdk

Import createExtensionClient() and use await client.whenReady() after the host sends the init message.

  • themegetTheme(), onThemeChange
  • agentsendMessage (JSON mode to the gateway), onStreamEvent (relies on GET /api/events SSE + host forwarding)
  • sessionlistSessions, navigateToSession
  • config / storage — backed by gateway REST (see below); storage is a JSON KV on disk per extension namespace
  • uishowNotification, navigate, resize, closePanel, onWidgetResult (tool/chat widget iframes receive tool output via the host widget.data event)
  • eventsemit / on use the ext.* namespace for cross-extension fan-out between iframes
  • onDispose, onDidChangeVisibility

Gateway REST (Bearer auth)

Same Authorization: Bearer <token> as the rest of the console API.

MethodPathDescription
GET/api/extensionsList discovered extensions and summarized ui
GET/api/extensions/:idDetail + full manifest
GET/api/extensions/:id/assets/*Serve static UI assets (HTML/JS/CSS/SVG); response includes a strict Content-Security-Policy
GET/api/extensions/:id/storageList storage keys
GET/api/extensions/:id/storage/:keyRead { value }
PUT/api/extensions/:id/storage/:keyBody { value } — write KV
DELETE/api/extensions/:id/storage/:keyRemove key
GET/api/extensions/:id/configRead extension-scoped config object
PATCH/api/extensions/:id/configMerge JSON patch into that config

Persistence: KV and config are stored as JSON under ~/.xopc/extensions/<sanitized_namespace>/storage.json. Config uses a dedicated namespace __config__<extensionId>.

Web shell behaviour

  • First-load permission prompt — Before mounting an iframe, the shell may show a dialog listing ui.permissions; approval is stored under localStorage key xopc.extensionUiGrants.v1 (keyed by extension id + permission-set fingerprint).
  • iframe sandbox — Typically allow-scripts allow-forms allow-popups without allow-same-origin for stronger isolation (host communication uses postMessage, not same-origin cookie access).
  • Agent stream — Webchat runs emit agent.stream on the gateway event bus; clients subscribed to GET /api/events receive them; the shell forwards matching chunks to iframes that subscribed via agent.subscribe for that sessionKey.
  • Command palette⌘K / Ctrl+K (or open-command-palette on window) lists contributions.commands; commands with opensPanel navigate to /apps/{extensionId}.
  • DebugSettings → Extensions → Extension debug lists gateway extensions and the raw UI grants JSON.

Sample: Hello extension

The Hello sample extension in the xopc repository demonstrates extension UI end-to-end. If you ship a ui panel, bundle its browser code (for example with esbuild) and point manifest.ui at the built assets.


Extension Structure

Manifest File

Each extension must include xopc.extension.json. Minimal example:

json
{
  "id": "my-extension",
  "name": "My Extension",
  "description": "A description of my extension",
  "version": "1.0.0",
  "main": "index.js",
  "kind": "utility",
  "configSchema": {
    "type": "object",
    "properties": {
      "option1": {
        "type": "string",
        "default": "value"
      }
    }
  }
}

Channel / provider extensions can add the optional declaration fields described in When extensions load; use the manifests of built-in extensions as examples when authoring your own.

Extension Entry File

typescript
import type { ExtensionApi } from '@xopcai/xopc/extension-sdk';

const extension = {
  id: 'my-extension',
  name: 'My Extension',
  description: 'Description here',
  version: '1.0.0',
  kind: 'utility',

  // Called when extension is registered
  register(api: ExtensionApi) {
    // Register tool
    api.registerTool({...});
    
    // Register command
    api.registerCommand({...});
    
    // Register hook
    api.registerHook('message_received', async (event, ctx) => {...});
    
    // Register HTTP route
    api.registerHttpRoute('/my-route', async (req, res) => {...});
  },

  // Called when extension is enabled
  activate(api: ExtensionApi) {
    console.log('Extension activated');
  },

  // Called when extension is disabled
  deactivate(api: ExtensionApi) {
    console.log('Extension deactivated');
  },
};

export default extension;

Core Concepts

Tools

Extensions can register custom tools:

typescript
api.registerTool({
  name: 'my_tool',
  description: 'Do something useful',
  parameters: {
    type: 'object',
    properties: {
      input: { 
        type: 'string', 
        description: 'Input value' 
      }
    },
    required: ['input']
  },
  async execute(params, ctx) {
    const input = params.input;
    // Perform operation
    return `Result: ${input}`;
  }
});

Speech providers (TTS)

Extensions can register a SpeechProviderPlugin so the TTS chain can call new vendors or local binaries without forking core. Built-in providers (openai, alibaba, edge, minimax) and the bundled local-CLI provider (tts-local-cli) all use the same plugin shape — see src/voice/tts/speech-provider-types.ts for the full interface.

Two ways to ship a provider:

1. Use the bundled tts-local-cli extension

For any local TTS binary (mlx-audio, sherpa-onnx-tts, piper, …), enable the bundled extension and configure the shell command in your xopc.json:

json
{
  "messages": {
    "tts": {
      "enabled": true,
      "provider": "tts-local-cli",
      "tts-local-cli": {
        "command": "mlx_audio.tts.generate --text \"{{Text}}\" --file_prefix {{OutputBase}}",
        "outputFormat": "wav",
        "timeoutMs": 120000
      }
    }
  }
}

Placeholders inside command: , , , (case-insensitive). The provider spawns the binary, scans OutputDir for the produced audio file, and returns its bytes. See docs/voice.mdLocal CLI TTS for the full config schema.

2. Write a custom SpeechProviderPlugin

Create a new extension and self-register the plugin on module load (this is the same pattern the bundled providers in src/voice/tts/providers/*-speech.ts use):

typescript
import { registerSpeechProvider } from 'xopc/voice/tts/speech-registry';
import type { SpeechProviderPlugin } from 'xopc/voice/tts/speech-provider-types';

const myProvider: SpeechProviderPlugin = {
  id: 'my-vendor',
  resolveConfig: (ctx) => ctx.rawConfig,
  isConfigured: (ctx) => Boolean(ctx.providerConfig.apiKey),
  async synthesize(req) {
    // ... call vendor API, return { audioBuffer, outputFormat, fileExtension }
  },
  // Optional: synthesizeStream for streaming consumers (Telegram drafts).
  // If omitted, the orchestrator wraps `synthesize` as a single-chunk stream.
};

registerSpeechProvider(myProvider);

Then list this provider id under messages.tts.provider (or in messages.tts.fallback.order) and the chain will pick it up. Outbound HTTP calls must go through src/media-shared/http/ (SSRF guard + key rotation are mandatory). See docs/voice-rearchitecture.md for the full design.

Hooks

Hooks intercept and modify behavior at lifecycle points:

HookTimingUse Case
before_agent_startBefore Agent startsModify system prompt
agent_endAfter Agent completesPost-process results
message_receivedWhen message receivedMessage pre-processing
message_sendingBefore sending messageIntercept/modify content
message_sentAfter message sentSend logging
before_tool_callBefore tool callParameter validation
after_tool_callAfter tool callResult processing
session_startSession startInitialization
session_endSession endCleanup

Example - Block sensitive content:

typescript
api.registerHook('message_sending', async (event, ctx) => {
  const { content } = event;

  // Block sensitive information
  if (content.includes('sensitive info')) {
    return {
      cancel: true,
      cancelReason: 'Content contains sensitive information'
    };
  }

  // Add signature
  if (content.includes('{{signature}}')) {
    return {
      content: content.replace(
        '{{signature}}', 
        '\n\n— Sent by AI Assistant'
      )
    };
  }
});

Example - Block dangerous tools:

typescript
api.registerHook('before_tool_call', async (event, ctx) => {
  const { toolName } = event;

  // Block dangerous operations
  if (toolName === 'delete_file' || toolName === 'execute_command') {
    return {
      block: true,
      blockReason: 'This operation is disabled for safety'
    };
  }
});

Commands

Register custom CLI commands:

typescript
api.registerCommand({
  name: 'status',
  description: 'Check extension status',
  acceptArgs: false,
  requireAuth: true,
  handler: async (args, ctx) => {
    return {
      content: 'Extension is running!',
      success: true
    };
  }
});

HTTP Routes

typescript
api.registerHttpRoute('/my-extension/status', async (req, res) => {
  res.json({ status: 'running', extension: 'my-extension' });
});

Gateway Methods

typescript
api.registerGatewayMethod('my-extension.status', async (params) => {
  return { status: 'running' };
});

Background Services

typescript
api.registerService({
  id: 'my-service',
  start(context) {
    // Start background task
    this.interval = setInterval(() => {
      // Scheduled task
    }, 60000);
  },
  stop(context) {
    if (this.interval) {
      clearInterval(this.interval);
    }
  }
});

Configuration Management

Define Configuration Schema

json
{
  "configSchema": {
    "type": "object",
    "properties": {
      "apiKey": {
        "type": "string",
        "description": "API Key for the service"
      },
      "maxResults": {
        "type": "number",
        "default": 10
      }
    },
    "required": ["apiKey"]
  }
}

Access Configuration

typescript
const apiKey = api.extensionConfig.apiKey;
const maxResults = api.extensionConfig.maxResults || 10;

Logging

typescript
api.logger.debug('Detailed debug information');
api.logger.info('General information');
api.logger.warn('Warning message');
api.logger.error('Error message');

Path Resolution

typescript
// Resolve workspace path
const configPath = api.resolvePath('config.json');

// Resolve extension relative path
const dataPath = api.resolvePath('./data.json');

Event System

typescript
// Emit event
api.emit('my-event', { key: 'value' });

// Listen for event
api.on('other-event', (data) => {
  console.log('Received:', data);
});

// Remove listener
api.off('my-event', handler);

Complete Example

typescript
import type { ExtensionApi } from '@xopcai/xopc/extension-sdk';

const extension = {
  id: 'example',
  name: 'Example Extension',
  description: 'A complete example extension',
  version: '1.0.0',
  kind: 'utility',
  configSchema: {
    type: 'object',
    properties: {
      enabled: { type: 'boolean', default: true }
    }
  },

  register(api) {
    // Register tool
    api.registerTool({
      name: 'example_tool',
      description: 'Example tool',
      parameters: {
        type: 'object',
        properties: { input: { type: 'string' } },
        required: ['input']
      },
      async execute(params) {
        return `Processed: ${params.input}`;
      }
    });

    // Register hook
    api.registerHook('message_received', async (event) => {
      console.log('Received:', event.content);
    });

    // Register command
    api.registerCommand({
      name: 'example',
      description: 'Example command',
      handler: async (args) => {
        return { content: 'Example!', success: true };
      }
    });
  },

  activate(api) {
    console.log('Extension activated');
  },

  deactivate(api) {
    console.log('Extension deactivated');
  }
};

export default extension;

Publishing Extensions

  1. Create xopc.extension.json manifest
  2. Create index.ts entry file
  3. Push to GitHub or publish to npm
bash
# Publish to npm (public)
npm publish --access public

# If using scoped package name (recommended)
# package.json: { "name": "@yourname/xopc-extension-name" }
npm publish --access public

Best Practices

  1. Error handling: All async operations should use try/catch
  2. Logging: Use the API's logging system instead of console
  3. Resource cleanup: Release resources in deactivate
  4. Configuration validation: Use JSON Schema to validate configuration
  5. Version management: Follow semantic versioning
  6. TypeScript: Use TypeScript for better type safety
  7. Minimal dependencies: Keep extensions lightweight

CLI Command Reference

extensions install

bash
# Install from npm
xopc extensions install <package-name>

# Install a specific version
xopc extensions install my-extension@1.0.0

# Install from a local directory
xopc extensions install ./local-extension-dir

# Install from xopc-store only
xopc extensions install --store weather

extensions list

bash
xopc extensions list
xopc extensions list --json

extensions health / audit / verify

bash
xopc extensions health
xopc extensions audit
xopc extensions verify [extension-id]

extensions dev / pack / publish

bash
xopc extensions dev ./local-extension-dir
xopc extensions pack ./local-extension-dir
xopc extensions publish ./local-extension-dir --dry-run

extensions search / update / freeze

bash
xopc extensions search [keyword]
xopc extensions update [extension-id]
xopc extensions freeze

Troubleshooting

Extension Not Loading

  1. Check extensions.disabled does not include the extension id
  2. For gateway / agent: confirm an activation trigger applies (extensions.enabled, matching channels.*, env vars declared in the manifest, model prefix, enabledByDefault, etc.)
  3. For CLI-only commands: remember extension code may be skipped unless extensions.enabled is non-empty, channels are configured, or a manifest-indexed env var is set
  4. Verify xopc.extension.json is valid JSON and the extension is discoverable under workspace / global / bundled paths
  5. Check logs for loading errors

Installation Failed

  1. Check network connection
  2. Verify package name is correct
  3. Check timeout setting for slow installations

Hook Not Triggering

  1. Verify hook name is correct
  2. Check if hook is registered in register() method
  3. Check logs for hook registration errors

Extension Configuration

Global options

Common keys under extensions in ~/.xopc/xopc.json:

json
{
  "extensions": {
    "enabled": ["hello", "echo"],
    "disabled": [],
    "security": {
      "checkPermissions": true,
      "allowUntrusted": false,
      "allow": ["hello", "echo", "xopc-feishu"],
      "trackProvenance": true,
      "allowPromptInjection": false
    },
    "slots": {
      "memory": "memory-lancedb",
      "tts": "elevenlabs"
    }
  }
}
OptionTypeDescription
enabledstring[]Extension ids to allow when combined with activation rules
disabledstring[]Extension ids that must not load
security.checkPermissionsbooleanPath and install safety checks
security.allowUntrustedbooleanAllow extensions outside security.allow
security.allowstring[]Optional allowlist of extension ids
security.trackProvenancebooleanRecord where an extension was installed from
security.allowPromptInjectionbooleanAllow hooks to alter system prompts
slots.memorystringPreferred memory backend extension id
slots.ttsstringPreferred TTS extension id
slots.imageGenerationstringPreferred image generation extension id
slots.webSearchstringPreferred web search extension id

Extension-Specific Configuration

Each extension can have its own custom configuration. Any fields not in the global config are treated as extension-specific:

json
{
  "extensions": {
    "feishu": {
      "appId": "cli_xxx",
      "appSecret": "yyy",
      "verificationToken": "zzz"
    },
    "memory-lancedb": {
      "vectorDim": 1536,
      "persistencePath": "~/data/memory"
    }
  }
}

The extension can access its config via api.extensionConfig:

typescript
// In your extension's register() or activate()
export function register(api: ExtensionApi) {
  const feishuConfig = api.extensionConfig as {
    appId: string;
    appSecret: string;
    verificationToken?: string;
  };
  
  console.log('Feishu App ID:', feishuConfig.appId);
}

Slot Configuration

Slots ensure exclusive capabilities have only one active implementation. Configure which extension should claim each slot:

json
{
  "extensions": {
    "slots": {
      "memory": "my-memory-extension",
      "tts": "my-tts-extension"
    }
  }
}

When a slot has a preferred plugin, other extensions requesting that slot will be rejected.

Security

By default, xopc performs security checks on extensions:

  • Path safety (no symlink escape)
  • Ownership validation
  • Hardlink detection
  • Provenance tracking

Set allowPromptInjection: true to allow extensions to modify system prompts via hook results.

Released under the MIT License.