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 asextension-sdk/core,extension-sdk/lazy). - TypeScript authoring — extensions may be authored in TypeScript, but distributable packages must point
mainat built.js,.mjs, or.cjscode; the loader does not compile source files. - Install sources — npm package, local folder, or xopc-store package via
xopc extensions install. - Gateway console UI (optional) — a
uiblock 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):
# Install from npm (~/.xopc/extensions)
xopc extensions install npm:xopc-extension-hello
# Install from local directory
xopc extensions install ./my-local-extension
# Install from xopc-store (SHA-256 is required and verified)
xopc extensions install store:hello
# View installed extensions
xopc extensions list
# Check extension health
xopc extensions healthEnable Extension
Configure in ~/.xopc/xopc.json:
{
"extensions": {
"enabled": ["hello", "echo"],
"hello": { "greeting": "Hi there!" },
"echo": true
}
}Configuration format:
| Field | Type | Description |
|---|---|---|
enabled | string[] | List of extension IDs to enable |
disabled | string[] | (Optional) List of extension IDs to disable |
[extension-id] | object | boolean | Extension-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
xopc extensions dev ./my-extension
xopc extensions pack ./my-extensionA valid extension project includes:
package.json- npm configindex.tsorindex.js- extension entryxopc.extension.json- extension manifestREADME.md- documentation
Three-tier Storage Architecture
xopc supports three-tier extension storage:
| Level | Path | Use Case | Priority |
|---|---|---|---|
| Workspace | workspace/.extensions/ | Project-private extensions | ⭐⭐⭐ Highest |
| Global | ~/.xopc/extensions/ | User-level shared extensions | ⭐⭐ Medium |
| Bundled | Inside the xopc install | Extensions 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):
extensions.enabled/extensions.disabled— explicit lists; avoid putting the same id in both.- Default agent model — if it matches optional
modelSupportpatterns on the manifest. - Environment variables — names listed under
providerAuthEnvVarsorchannelEnvVarson the manifest when they are set. autoEnableWhenConfiguredProviders— when yourprovidersblock matches declared provider ids.activation.onProviders/activation.onChannels— when configured providers or channels match.enabledByDefault: trueon the manifest.
Where this applies
- Gateway and
xopc agentevaluate the full plan when they start extensions. - Other CLI commands may skip loading extensions unless
extensions.enabledis 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:
| Field | Purpose |
|---|---|
enabledByDefault | Turn on when nothing above overrides |
providers, channels | Logical ids this extension implements |
providerAuthEnvVars, channelEnvVars | Map ids to env var names for detection |
providerAuthChoices | Hints for UI/CLI auth flows |
modelSupport | modelPrefixes, modelPatterns for model-based activation |
autoEnableWhenConfiguredProviders | Auto-enable when listed providers appear in config |
activation | onProviders, onChannels, onCommands, onCapabilities |
contracts, setup | Runtime capability declarations and setup hints. Runtime registration must match contracts. |
Extension SDK
The npm package name is @xopcai/xopc. Import the SDK through the published subpath:
// Recommended: published package subpath
import type { ExtensionApi, ExtensionDefinition } from '@xopcai/xopc/extension-sdk';Distributed extensions must treat this SDK as host-provided: keep @xopcai/xopc in devDependencies for types/build tooling (and optionally as an optional peer dependency for compatibility metadata), but do not put it in runtime dependencies. Extension runtime code must import SDK APIs from @xopcai/xopc/extension-sdk and its documented subpaths.
Exported Types
// 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)
| Field | Role |
|---|---|
main | Default panel entry (path relative to the extension package) |
icon | Icon asset path |
permissions | Strings gating SDK calls on the host (theme, agent.send, agent.subscribe, storage, …) |
contributions | pages, 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.
- theme —
getTheme(),onThemeChange - agent —
sendMessage(JSON mode to the gateway),onStreamEvent(relies onGET /api/eventsSSE + host forwarding) - session —
listSessions,navigateToSession - config / storage — backed by gateway REST (see below); storage is a JSON KV on disk per extension namespace
- ui —
showNotification,navigate,resize,closePanel,onWidgetResult(tool/chat widget iframes receive tool output via the hostwidget.dataevent) - events —
emit/onuse theext.*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.
| Method | Path | Description |
|---|---|---|
| GET | /api/extensions | List discovered extensions and summarized ui |
| GET | /api/extensions/:id | Detail + 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/storage | List storage keys |
| GET | /api/extensions/:id/storage/:key | Read { value } |
| PUT | /api/extensions/:id/storage/:key | Body { value } — write KV |
| DELETE | /api/extensions/:id/storage/:key | Remove key |
| GET | /api/extensions/:id/config | Read extension-scoped config object |
| PATCH | /api/extensions/:id/config | Merge 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 underlocalStoragekeyxopc.extensionUiGrants.v1(keyed by extension id + permission-set fingerprint). - iframe
sandbox— Typicallyallow-scripts allow-forms allow-popupswithoutallow-same-originfor stronger isolation (host communication usespostMessage, not same-origin cookie access). - Agent stream — Webchat runs emit
agent.streamon the gateway event bus; clients subscribed toGET /api/eventsreceive them; the shell forwards matching chunks to iframes that subscribed viaagent.subscribefor thatsessionKey. - Command palette — ⌘K / Ctrl+K (or
open-command-paletteonwindow) listscontributions.commands; commands withopensPanelnavigate to/extensions/{extensionId}. - Debug — Settings → 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:
{
"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
Runtime registrations must be declared in xopc.extension.json contracts. For example, an extension that registers my_tool must include "contracts": { "tools": ["my_tool"] }; undeclared runtime capabilities are rejected by the host.
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:
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:
{
"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.md → Local 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):
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:
| Hook | Timing | Use Case |
|---|---|---|
before_agent_start | Before Agent starts | Modify system prompt |
agent_end | After Agent completes | Post-process results |
message_received | When message received | Message pre-processing |
message_sending | Before sending message | Intercept/modify content |
message_sent | After message sent | Send logging |
before_tool_call | Before tool call | Parameter validation |
after_tool_call | After tool call | Result processing |
session_start | Session start | Initialization |
session_end | Session end | Cleanup |
Example - Block sensitive content:
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:
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:
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
api.registerHttpRoute('/my-extension/status', async (req, res) => {
res.json({ status: 'running', extension: 'my-extension' });
});Gateway Methods
api.registerGatewayMethod('my-extension.status', async (params) => {
return { status: 'running' };
});Background Services
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
{
"configSchema": {
"type": "object",
"properties": {
"apiKey": {
"type": "string",
"description": "API Key for the service"
},
"maxResults": {
"type": "number",
"default": 10
}
},
"required": ["apiKey"]
}
}Access Configuration
const apiKey = api.extensionConfig.apiKey;
const maxResults = api.extensionConfig.maxResults || 10;Logging
api.logger.debug('Detailed debug information');
api.logger.info('General information');
api.logger.warn('Warning message');
api.logger.error('Error message');Path Resolution
// Resolve workspace path
const configPath = api.resolvePath('config.json');
// Resolve extension relative path
const dataPath = api.resolvePath('./data.json');Event System
// 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
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
- Create
xopc.extension.jsonmanifest withengines.xopc - Build a JavaScript runtime entry such as
dist/index.js - Bundle any Gateway UI iframe code into static assets (do not ship bare browser imports)
- Validate the independent package contract
- Push to GitHub or publish to npm / xopc-store
xopc extensions create my-extension --template tool
xopc extensions pack ./my-extension --dry-run
xopc extensions pack ./my-extension --out ./release
xopc extensions publish ./my-extension --dry-run
# Publish to npm (public)
npm publish --access public
# If using scoped package name (recommended)
# package.json: { "name": "@yourname/xopc-extension-name" }
npm publish --access publicPublished packages must not contain workspace:* in dependencies, devDependencies, peerDependencies, or optionalDependencies. @xopcai/extension-ui-sdk is a build-time dependency: bundle it into ui/*.bundle.js and keep it out of runtime dependencies.
xopc extensions pack produces a store-ready artifact set: .zip, .sha256, and .manifest.json. The metadata contains both raw hexadecimal sha256 and SRI-formatted integrity; a store release must preserve the raw digest for installers. Packaging also rejects unsafe contents such as .env, private key files, and common token patterns. The ui template includes @xopcai/extension-ui-sdk, esbuild, scripts/build-ui.mjs, and a build:ui script that bundles ui/panel-entry.ts to ui/panel.bundle.js.
Best Practices
- Error handling: All async operations should use try/catch
- Logging: Use the API's logging system instead of console
- Resource cleanup: Release resources in
deactivate - Configuration validation: Use JSON Schema to validate configuration
- Version management: Follow semantic versioning
- TypeScript: Use TypeScript for better type safety
- Minimal dependencies: Keep extensions lightweight
CLI Command Reference
extensions install
# Install from npm
xopc extensions install npm:<package-name>
# Install a specific version
xopc extensions install npm:my-extension@1.0.0
# Install from a local directory
xopc extensions install ./local-extension-dir
# Install from xopc-store
xopc extensions install store:weatherThe source is intentionally explicit: use store:, npm:, or an existing local directory path. Store installs require a release-provided raw SHA-256 digest, are staged and import-checked before their live directory is atomically replaced, and request a gateway restart after success.
extensions list
xopc extensions list
xopc extensions list --jsonextensions inspect / health / audit / verify
xopc extensions inspect <extension-id>
xopc extensions inspect <extension-id> --runtime
xopc extensions inspect <extension-id> --json
xopc extensions doctor
xopc extensions doctor --fix
xopc extensions doctor --json
xopc extensions health
xopc extensions audit
xopc extensions verify [extension-id]extensions create / dev / pack / publish
xopc extensions create my-extension --template tool
xopc extensions create my-ui-extension --template ui
xopc extensions create my-provider --template provider
xopc extensions create my-channel --template channel
xopc extensions dev ./local-extension-dir
xopc extensions pack ./local-extension-dir --out ./release
xopc extensions publish ./local-extension-dir --dry-runextensions search / update / freeze
xopc extensions search [keyword]
xopc extensions update [extension-id]
xopc extensions freezePost-update extension sync
When xopc update (or gateway POST /api/update/run) succeeds, xopc automatically reinstalls extensions recorded in ~/.xopc/extensions.lock:
npm—resolvedspec is packed and installed under~/.xopc/extensions/store— latest zip from the configured xopc-store base URLlocal/git— skipped (not managed by the post-update sync)
On the dev update channel, extensions whose id also exists in the bundled tree are skipped so the shipped copy stays authoritative.
Manual equivalent:
xopc extensions update # all lockfile entries
xopc extensions update my-ext # one idSee Updates for the full core-update + restart pipeline.
Troubleshooting
Extension Not Loading
- Check
extensions.disableddoes not include the extension id - For gateway / agent: confirm an activation trigger applies (
extensions.enabled, matchingchannels.*, env vars declared in the manifest, model prefix,enabledByDefault, etc.) - For CLI-only commands: remember extension code may be skipped unless
extensions.enabledis non-empty, channels are configured, or a manifest-indexed env var is set - Verify
xopc.extension.jsonis valid JSON and the extension is discoverable under workspace / global / bundled paths - Check logs for loading errors
Installation Failed
- Check network connection
- Verify package name is correct
- Check timeout setting for slow installations
Hook Not Triggering
- Verify hook name is correct
- Check if hook is registered in
register()method - Check logs for hook registration errors
Extension Configuration
Global options
Common keys under extensions in ~/.xopc/xopc.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"
}
}
}| Option | Type | Description |
|---|---|---|
enabled | string[] | Extension ids to allow when combined with activation rules |
disabled | string[] | Extension ids that must not load |
security.checkPermissions | boolean | Path and install safety checks |
security.allowUntrusted | boolean | Allow extensions outside security.allow |
security.allow | string[] | Optional allowlist of extension ids |
security.trackProvenance | boolean | Record where an extension was installed from |
security.allowPromptInjection | boolean | Allow hooks to alter system prompts |
slots.memory | string | Preferred memory backend extension id |
slots.tts | string | Preferred TTS extension id |
slots.imageGeneration | string | Preferred image generation extension id |
slots.webSearch | string | Preferred 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:
{
"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:
// 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:
{
"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.