Skip to content

Plugin Development

xNet’s plugin system is organized into four layers of increasing complexity:

LayerNameDescriptionPlatform
1ScriptsSandboxed expressions. Safe for AI-generated code.All
2ExtensionsDeveloper-built packages with views, commands, editor integrations.All (some features Electron-only)
3ServicesBackground processes managed by a process supervisor.Electron only
4IntegrationsHTTP API, MCP server, webhooks for external tools.Electron only

Most plugin development happens at Layer 2. Layers 3 and 4 are for advanced use cases that need OS-level access.

  1. Define the manifest

    Every extension starts with an XNetExtension manifest. Use defineExtension() for validation:

    import { defineExtension } from '@xnetjs/plugins'
    export const MyPlugin = defineExtension({
    id: 'com.example.my-plugin', // reverse-domain format
    name: 'My Plugin',
    version: '1.0.0',
    description: 'Adds a custom view and slash command.',
    platforms: ['electron', 'web'],
    contributes: {
    // Static contributions declared here
    },
    activate(ctx) {
    // Dynamic contributions registered here
    },
    deactivate() {
    // Cleanup (optional — subscriptions auto-dispose)
    }
    })
  2. Register it

    In your app setup, install the plugin with the registry:

    import { usePluginRegistry } from '@xnetjs/react'
    import { MyPlugin } from './plugins/my-plugin'
    function App() {
    const registry = usePluginRegistry()
    useEffect(() => {
    registry.install(MyPlugin)
    }, [])
    }
  3. Consume contributions in your UI

    import { useViews, useCommands } from '@xnetjs/react'
    function Sidebar() {
    const views = useViews()
    const commands = useCommands()
    // Render plugin-contributed views and commands
    }

The XNetExtension interface:

interface XNetExtension {
id: string // Reverse-domain: 'com.example.my-plugin'
name: string
version: string // Semver: '1.0.0'
description?: string
author?: string
xnetVersion?: string // Minimum compatible xNet version
platforms?: Platform[] // 'web' | 'electron' | 'mobile' (default: all)
permissions?: PluginPermissions
contributes?: PluginContributions // Static contributions
activate?(ctx: ExtensionContext): void | Promise<void>
deactivate?(): void | Promise<void>
}

Validation rules enforced by defineExtension():

  • id must match /^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$/i (reverse-domain)
  • version must be valid semver
  • platforms values must be 'web', 'electron', or 'mobile'
  • activate and deactivate must be functions if present
install → activate → contribute → deactivate → uninstall
  1. Install — Manifest is validated, platform compatibility checked, plugin persisted as a node in the store (syncs to peers). Status: 'installed'. Activation happens immediately.

  2. Activate — An ExtensionContext is created. Static contributions from contributes are registered. Then activate(ctx) is called if defined — this is where you register dynamic contributions, subscribe to data changes, or add middleware.

  3. Contribute — All ctx.register*() calls return Disposable objects tracked in ctx.subscriptions. Contributions are visible to React hooks immediately.

  4. Deactivatedeactivate() is called, then all subscriptions are disposed. Contributions are unregistered, middleware removed, store subscriptions cancelled.

  5. Uninstall — Plugin node deleted from the store.

The context object passed to activate() provides the full plugin API:

interface ExtensionContext {
readonly pluginId: string
readonly platform: Platform
readonly store: NodeStore
readonly storage: ExtensionStorage // Key-value storage scoped to this plugin
readonly capabilities: PlatformCapabilities
readonly subscriptions: Disposable[]
// Data access
query(schema: SchemaIRI, filter?: QueryFilter): Promise<NodeState[]>
subscribe(schema: SchemaIRI | null, callback): Disposable
// Registration (all return Disposable)
registerSchema(schema): Disposable
registerView(view: ViewContribution): Disposable
registerCommand(command: CommandContribution): Disposable
registerSlashCommand(cmd: SlashCommandContribution): Disposable
registerEditorExtension(ext: EditorContribution): Disposable
registerSidebarItem(item: SidebarContribution): Disposable
registerPropertyHandler(type, handler): Disposable
registerBlockType(block: BlockContribution): Disposable
addMiddleware(middleware: NodeStoreMiddleware): Disposable
}

xNet supports eleven contribution types. Each can be declared statically in contributes or registered dynamically in activate().

Custom UI panels for displaying node data:

interface ViewContribution {
type: string // Unique view type identifier
name: string
icon?: string | ComponentType
component: ComponentType<ViewProps> // React component
supportedSchemas?: string[] // Restrict to specific schemas
}
interface ViewProps {
nodeId: string
schemaId: string
}
const KanbanView: ViewContribution = {
type: 'kanban',
name: 'Kanban Board',
icon: 'columns',
supportedSchemas: ['xnet://app/Task'],
component: ({ nodeId, schemaId }) => {
const { data } = useQuery(schemaId)
return <KanbanBoard tasks={data} />
}
}

Actions triggered by keyboard shortcuts or the command palette:

interface CommandContribution {
id: string
name: string
description?: string
keybinding?: string // e.g., 'Mod-Shift-P' (Mod = Cmd on Mac, Ctrl elsewhere)
keywords?: string[] // For command palette search
icon?: string
execute: () => void | Promise<void>
when?: () => boolean // Show/enable condition
}
ctx.registerCommand({
id: 'my-plugin.export-csv',
name: 'Export as CSV',
keybinding: 'Mod-Shift-E',
keywords: ['export', 'csv', 'download'],
execute: async () => {
const nodes = await ctx.query('xnet://app/Task')
downloadCSV(nodes)
}
})

In-editor commands triggered by typing /:

interface SlashCommandContribution {
id: string
name: string
description?: string
aliases?: string[] // Alternative trigger words
icon?: string
execute: (props: SlashCommandContext) => void
}
interface SlashCommandContext {
editor: unknown // TipTap Editor instance
range: { from: number; to: number }
}

TipTap editor extensions (nodes, marks, plugins):

interface EditorContribution {
id: string
extension: Extension // TipTap Extension/Node/Mark
priority?: number // Default: 100. Lower runs first.
toolbar?: ToolbarContribution
}
interface ToolbarContribution {
icon: string | ComponentType
title: string
group?: 'format' | 'insert' | 'block' | 'custom'
isActive?: (editor) => boolean
action: (editor) => void
shortcut?: string
}

Add items to the application sidebar:

interface SidebarContribution {
id: string
name: string
icon: string | ComponentType
position?: 'top' | 'bottom' | 'section'
section?: string
priority?: number
badge?: () => string | number | null
action: (() => void) | string // Function or route string
panel?: ComponentType // Expandable panel content
}

In the workbench shell, a SidebarContribution becomes a rail item; a contribution with a panel component becomes a left-panel view. See The Workbench for the container model.

Add items to the workbench status bar — workspace scope on the left, view scope on the right:

interface StatusBarContribution {
id: string
alignment: 'left' | 'right'
priority?: number
item: ComponentType // Rendered in the 24px mono status bar
}

Register widgets users can add to dashboards (and drop onto canvases) with a WidgetContribution. The trust tier is host-assigned — a plugin can never self-declare one. See Dashboards & Widgets for the widget contract.

Custom renderers for property types in table cells and editors:

interface PropertyHandlerContribution {
type: string // Property type identifier
handler: {
Cell: ComponentType<{ value; config? }> // Read-only display
Editor: ComponentType<{ value; onChange; config? }> // Edit mode
parse?: (input: string) => unknown
format?: (value: unknown) => string
validate?: (value: unknown) => boolean
}
}

Custom block-level elements inside the editor:

interface BlockContribution {
type: string
name: string
component: ComponentType<{
node: unknown
updateAttributes: (attrs: Record<string, unknown>) => void
}>
}

Add sections to the application settings UI:

interface SettingContribution {
id: string
title: string
description?: string
icon?: string
section?: 'general' | 'appearance' | 'plugins' | 'data' | 'network'
component: ComponentType<{
storage: ExtensionStorage
}>
}

Register new schemas from a plugin:

interface SchemaContribution {
schema: unknown // A defineSchema() result
}

Use these hooks to consume plugin contributions in your components:

HookReturnsDescription
usePlugins()RegisteredPlugin[]All registered plugins with status
useViews()ViewContribution[]All view contributions
useCommands()CommandContribution[]All command contributions
useSlashCommands()SlashCommandContribution[]All slash commands
useSidebarItems()SidebarContribution[]All sidebar items
useEditorExtensions()EditorContribution[]All editor extensions (throws if no plugin system)
useEditorExtensionsSafe()EditorContribution[]Same, returns [] if no plugin system
useContributions(type)Contribution[]Generic — pass 'views', 'commands', etc.
useView(type)ViewContribution | undefinedFind a specific view by type
useCommand(id)CommandContribution | undefinedFind a specific command by ID
usePluginRegistry()PluginRegistryDirect registry access (throws if missing)

All hooks are reactive — they re-render when contributions change.

Middleware lets you intercept NodeStore operations. This is useful for validation, audit logging, computed fields, or cross-plugin data transformation.

interface NodeStoreMiddleware {
id: string
priority?: number // Lower runs first. Default: 100.
beforeChange?(change: PendingChange, next: () => Promise<unknown>): Promise<unknown>
afterChange?(event: NodeChangeEvent): void
}
ctx.addMiddleware({
id: 'my-plugin.audit-log',
priority: 50, // Run early
async beforeChange(change, next) {
// Pass through — let the operation proceed
const result = await next()
return result
},
afterChange({ change, node, isRemote }) {
if (!isRemote) {
console.log(`[audit] ${change.type} on ${change.nodeId}`)
}
}
})
  • Pass through: Call next() to continue the chain
  • Modify: Mutate the change object before calling next()
  • Reject: Throw an error to abort the operation
  • Short-circuit: Return a value without calling next()

The MiddlewareChain executes beforeChange hooks in priority order (lowest first). After the store operation completes, afterChange hooks run for all middleware — one failing hook does not prevent others.

Scripts are sandboxed expressions for safe, user-authored (or AI-generated) logic. They run in a restricted JavaScript subset with no access to the DOM, network, or Node.js APIs.

Scripts are stored as nodes with schema xnet://xnet.dev/Script:

interface ScriptNode {
name: string
description?: string
code: string // The script body
triggerType: 'manual' | 'onChange' | 'onView' | 'scheduled'
triggerProperty?: string // For onChange: which property triggers re-run
inputSchema?: string // SchemaIRI to filter triggers
outputType: 'value' | 'mutation' | 'decoration' | 'void'
enabled: boolean
lastError?: string
lastRun?: number
}

Scripts receive a frozen context with helper libraries:

interface ScriptContext {
node: Readonly<FlatNode> // Current node
nodes: (schemaIRI?) => ReadonlyArray<FlatNode> // Query siblings
now: () => number // Current timestamp
format: { date; number; currency; relativeTime; bytes }
math: { sum; avg; min; max; round; clamp; abs; floor; ceil }
text: { slugify; truncate; capitalize; titleCase; contains; template; trim; lower; upper }
array: { first; last; sortBy; groupBy; unique; count; compact }
}
// Compute a "progress" value from subtask completion
const tasks = nodes('xnet://app/Task')
const done = array.count(tasks, (t) => t.status === 'done')
const total = tasks.length
total > 0 ? math.round((done / total) * 100) : 0

Before execution, scripts are parsed with acorn and validated against a strict allowlist. Blocked patterns include:

  • 60+ forbidden globals (window, document, fetch, eval, require, process, setTimeout, etc.)
  • Forbidden property access (__proto__, constructor, prototype)
  • Import/export statements and dynamic import()
  • async/await, new Function(), with statements
import { ScriptSandbox, createScriptContext } from '@xnetjs/plugins'
const sandbox = new ScriptSandbox({ timeoutMs: 1000 })
const context = createScriptContext(node, queryFn)
const result = await sandbox.execute(code, context)

The sandbox wraps code in a new Function() with all dangerous globals shadowed to undefined. Output is sanitized — functions, symbols, circular references, and non-plain objects are stripped.

xNet includes an AI script generator that produces validated scripts:

import { generateScript, createAIProvider } from '@xnetjs/plugins'
const provider = createAIProvider({
type: 'anthropic', // or 'openai', 'ollama'
options: { apiKey: '...' }
})
const result = await generateScript(provider, {
intent: 'Calculate total price from quantity and unit price',
schema: { name: 'LineItem', schemaIRI: '...', properties: [...] },
outputType: 'value'
})
if (result.validated) {
// result.code is safe to execute in the sandbox
}

The generator builds a structured prompt with schema context and helper documentation, then validates the output through the AST validator. It retries up to 2 times if validation fails.

Services are background processes managed by ProcessManager in the Electron main process. Think of them as supervised daemons.

interface ServiceDefinition {
id: string
name: string
process: {
command: string // e.g., 'python', 'node'
args?: string[]
cwd?: string
env?: Record<string, string>
}
lifecycle: {
restart: 'always' | 'on-failure' | 'never'
maxRestarts?: number
restartDelayMs?: number
healthCheck?: {
type: 'http' | 'tcp' | 'stdout'
url?: string
port?: number
intervalMs?: number
}
}
communication: {
protocol: 'stdio' | 'http' | 'websocket' | 'ipc'
port?: number
}
}

From the renderer, use the ServiceClient:

import { createServiceClient } from '@xnetjs/plugins'
const services = createServiceClient()
await services.start(myServiceDef)
const status = await services.status('my-service')
// { state: 'running', pid: 12345, uptime: 60000, ... }
const result = await services.call('my-service', 'POST', '/analyze', { data })

The service client communicates with the main process via IPC. It is only available in Electron.

A REST API on localhost:31415 for external tools:

GET /api/v1/nodes?schema=&limit=&offset=
POST /api/v1/nodes { schema, properties }
GET /api/v1/nodes/:id
PATCH /api/v1/nodes/:id
DELETE /api/v1/nodes/:id
POST /api/v1/query { schema, limit?, offset? }
GET /api/v1/events?since= Polling endpoint
GET /api/v1/schemas

Optional Bearer token authentication. CORS enabled.

Exposes xNet data to AI agents via the Model Context Protocol:

ToolDescription
xnet_queryQuery nodes by schema
xnet_getGet a node by ID
xnet_createCreate a node
xnet_updateUpdate node properties
xnet_deleteDelete a node
xnet_schemasList all schemas

The MCP server reads from stdin and writes JSON-RPC 2.0 responses to stdout.

Push notifications on store changes:

import { createWebhookEmitter } from '@xnetjs/plugins'
const emitter = createWebhookEmitter(store)
emitter.register({
id: 'notify-slack',
url: 'https://hooks.slack.com/...',
events: ['created', 'updated'],
schema: 'xnet://app/Task',
secret: 'hmac-secret',
retries: 3
})
emitter.start()

Payloads are signed with HMAC-SHA256 (x-xnet-signature-256 header). Failed deliveries retry with exponential backoff.

Here is the complete source for the built-in Mermaid diagram plugin:

import type { XNetExtension } from '@xnetjs/plugins'
import { MermaidExtension } from '@xnetjs/editor/extensions'
export const MermaidPlugin: XNetExtension = {
id: 'fyi.xnet.mermaid',
name: 'Mermaid Diagrams',
version: '1.0.0',
description: 'Add flowcharts, sequence diagrams, and more using Mermaid syntax.',
author: 'xNet',
platforms: ['electron', 'web'],
contributes: {
editorExtensions: [
{
id: 'mermaid',
extension: MermaidExtension,
priority: 100
}
],
slashCommands: [
{
id: 'mermaid',
name: 'Mermaid Diagram',
description: 'Insert a Mermaid diagram',
aliases: ['diagram', 'flowchart', 'sequence', 'chart'],
icon: 'git-branch',
execute: ({ editor, range }) => {
;(editor as any).chain().focus().deleteRange(range).setMermaid().run()
}
}
]
}
}

Key takeaways:

  • No activate/deactivate — simple plugins only need contributes
  • Two contributions — an editor extension and a slash command that work together
  • Platform restriction — excluded from mobile where the editor isn’t available
  • Bundled plugins are installed from BUNDLED_PLUGINS in the app entry point and rehydrated on restart using registry.rehydrate() to restore live function references after deserialization

Plugins can declare the permissions they need:

permissions: {
schemas: {
read: ['xnet://app/Task', 'xnet://app/Project'],
write: ['xnet://app/Task'],
create: ['xnet://app/Task']
},
capabilities: {
network: ['https://api.example.com'], // or true for all
storage: 'local', // or 'shared'
clipboard: true,
notifications: true,
processes: true // Electron only
}
}
FeatureElectronWebMobile
ViewsYesYesYes
CommandsYesYesYes
Slash commandsYesYesNo
Editor extensionsYesYesNo
Sidebar itemsYesYesYes
Property handlersYesYesYes
Block typesYesYesNo
Settings panelsYesYesYes
Scripts (sandbox)YesYesYes
Services (processes)YesNoNo
Local APIYesNoNo
MCP serverYesNoNo
WebhooksYesYesYes

If a plugin specifies platforms and the current platform isn’t included, installation is rejected. Use ctx.capabilities to check feature availability at runtime.

Ecosystem APIs: capabilities, marketplace, testing

Section titled “Ecosystem APIs: capabilities, marketplace, testing”

The @xnetjs/plugins ecosystem layer turns a FeatureModule manifest into something installable, trustworthy, and testable. Every export below is available from @xnetjs/plugins.

A FeatureModule declares a capabilities grant. At install, the registry hands the plugin a NodeStore wrapped by a guard, so a write outside the grant throws a CapabilityError — the declaration is a runtime gate, not a label.

import { defineFeatureModule } from '@xnetjs/plugins'
export const KanbanModule = defineFeatureModule({
id: 'com.acme.kanban',
name: 'Kanban Board',
version: '1.0.0',
capabilities: {
schemaWrite: ['xnet://xnet.fyi/Task@*'], // may write any Task version…
network: ['api.acme.com'] // …and reach only this host
}
})
// Inside activate(ctx): ctx.store.create({ schemaId: 'xnet://xnet.fyi/Secret@1.0.0' })
// → throws CapabilityError — Secret is outside the grant.

Patterns: exact (…/Task@1.0.0), version-wildcard (…/Task@*), authority-wildcard (…/fyi/*), or * (all — rendered as a danger in the consent dialog). Reads default open; declare schemaRead to restrict them.

Section titled “Provenance, trust tier, and install consent”

A plugin’s trust tier follows its provenance (where it came from), never anything it declares about itself. Pass provenance to install; the registry derives the tier and prompts for consent when needed.

import { evaluateInstallConsent } from '@xnetjs/plugins'
await registry.install(manifest, {
provenance: 'marketplace', // → marketplace tier (iframe sandbox)
hostVersion: '0.7.0', // gates on manifest.xnetVersion
onConsent: async (decision) => {
// decision.lines describes each requested capability (danger-flagged)
return confirm(decision.lines.map((l) => l.text).join('\n'))
}
})

deriveTrustTier/requiresCapabilityReprompt map provenance to tier and consent: builtin→first-party, authored/ai-generated/imported/synced→user, marketplace→marketplace. Synced and marketplace installs always re-prompt — sync is not consent.

Declare a host requirement with xnetVersion (a semver range) and other plugins with dependencies. Install gates on both.

defineFeatureModule({
id: 'com.acme.app',
name: 'App',
version: '1.0.0',
xnetVersion: '>=0.6.0', // satisfiesRange / isHostCompatible
dependencies: { 'com.acme.core': '^1.2.0' } // resolveInstallOrder / findMissingDependencies
})

Marketplace index and supply-chain provenance

Section titled “Marketplace index and supply-chain provenance”

The marketplace is a GitHub-hosted registry.json of MarketplaceEntrys. MarketplaceClient fetches once and searches in memory; verifyProvenance checks a Sigstore-style attestation and fails closed (no verifier configured ⇒ “unverified”, never a false green).

import { MarketplaceClient, verifyProvenance } from '@xnetjs/plugins'
const market = new MarketplaceClient({ indexUrl: REGISTRY_URL })
const results = await market.search('tasks', { sort: 'installs', category: 'productivity' })
const { verified, reason } = await verifyProvenance({ artifactDigest, provenance })

createTestPluginHarness wires an in-memory store into a real PluginRegistry, so you can install a manifest and assert on its contributions without the app.

import { createTestPluginHarness } from '@xnetjs/plugins'
const harness = createTestPluginHarness()
await harness.install(KanbanModule)
expect(harness.registry.get('com.acme.kanban')?.status).toBe('active')
expect(harness.registry.getContributions().slashCommands.getAll()).toHaveLength(1)

xnet plugin scaffold writes a ready-to-edit project (manifest, harness test, package.json, README) from one of three templates — client, two-sided, or ai-script:

Terminal window
xnet plugin scaffold com.acme.kanban --name "Kanban Board" --template client
# → com.acme.kanban/{package.json,tsconfig.json,src/index.ts,src/index.test.ts,README.md}

The pure core, scaffoldPlugin(spec), returns the project as a path→content map, so tooling can generate plugins without touching disk:

import { scaffoldPlugin } from '@xnetjs/plugins'
const { files } = scaffoldPlugin({ id: 'com.acme.kanban', name: 'Kanban', template: 'client' })
// files['src/index.ts'] is a defineFeatureModule(...) ; files['src/index.test.ts'] uses the harness

The AI generators in @xnetjs/plugins/ai turn natural language into an AST-validated script. scriptToPluginManifest wraps a validated generated script into an installable plugin stamped with ai-generated provenance (so the install path sandboxes it at the user tier and still routes its capability requests through consent). It refuses unvalidated code — “the AI made it” never bypasses the gate.

import { scriptToPluginManifest } from '@xnetjs/plugins'
const { manifest, provenance } = scriptToPluginManifest({
id: 'com.me.counter',
script: generated, // { code, suggestedName, validated, explanation } from the AI generator
run: (code) => runInSandbox(code) // how the command executes the script
})
await registry.install(manifest, { provenance }) // provenance === 'ai-generated'

A plugin that declares network hosts is handed a guardedFetch instead of the ambient fetch. Every request is checked against the allowlist before it leaves; an undeclared host rejects with CapabilityError, and a plugin with no network grant gets no egress at all.

import { guardedFetch } from '@xnetjs/plugins'
const fetch = guardedFetch({ network: ['api.acme.com'] }, 'com.acme.kanban')
await fetch('https://api.acme.com/cards') // ok
await fetch('https://evil.example.com') // rejects: CapabilityError

Your plugin’s code stays in your own repo — getting listed on the plugins marketplace is a one-line pull request, not a code dump into the monorepo.

  1. Ship a Release. Build your plugin into a single plugin.js and attach it, with manifest.json, to a GitHub Release. (A copyable template with a release workflow lives in examples/xnet-plugin-template.)

  2. Add one entry to registry/community.json:

    [{ "repo": "you/xnet-plugin-kanban", "category": "views" }]
  3. Open a PR. CI validates the submission; after merge, a daily job enriches your entry from the GitHub API and points the install link at your Release’s manifest.json. Updates you ship afterward are picked up automatically.

The app installs community plugins in a sandbox scoped to their trust tier, after showing the user the capabilities the plugin requests and verifying its provenance. See registry/README.md for the full flow.