Plugin Development
The four layers
Section titled “The four layers”xNet’s plugin system is organized into four layers of increasing complexity:
| Layer | Name | Description | Platform |
|---|---|---|---|
| 1 | Scripts | Sandboxed expressions. Safe for AI-generated code. | All |
| 2 | Extensions | Developer-built packages with views, commands, editor integrations. | All (some features Electron-only) |
| 3 | Services | Background processes managed by a process supervisor. | Electron only |
| 4 | Integrations | HTTP 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.
Quick start: your first extension
Section titled “Quick start: your first extension”-
Define the manifest
Every extension starts with an
XNetExtensionmanifest. UsedefineExtension()for validation:import { defineExtension } from '@xnetjs/plugins'export const MyPlugin = defineExtension({id: 'com.example.my-plugin', // reverse-domain formatname: '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)}}) -
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)}, [])} -
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}
Manifest reference
Section titled “Manifest reference”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():
idmust match/^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$/i(reverse-domain)versionmust be valid semverplatformsvalues must be'web','electron', or'mobile'activateanddeactivatemust be functions if present
Lifecycle
Section titled “Lifecycle”install → activate → contribute → deactivate → uninstall-
Install — Manifest is validated, platform compatibility checked, plugin persisted as a node in the store (syncs to peers). Status:
'installed'. Activation happens immediately. -
Activate — An
ExtensionContextis created. Static contributions fromcontributesare registered. Thenactivate(ctx)is called if defined — this is where you register dynamic contributions, subscribe to data changes, or add middleware. -
Contribute — All
ctx.register*()calls returnDisposableobjects tracked inctx.subscriptions. Contributions are visible to React hooks immediately. -
Deactivate —
deactivate()is called, then all subscriptions are disposed. Contributions are unregistered, middleware removed, store subscriptions cancelled. -
Uninstall — Plugin node deleted from the store.
ExtensionContext
Section titled “ExtensionContext”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}Contribution types
Section titled “Contribution types”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} /> }}Commands
Section titled “Commands”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) }})Slash commands
Section titled “Slash commands”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 }}Editor extensions
Section titled “Editor extensions”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}Sidebar items
Section titled “Sidebar items”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.
Status bar items
Section titled “Status bar items”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}Dashboard widgets
Section titled “Dashboard widgets”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.
Property handlers
Section titled “Property handlers”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 }}Block types
Section titled “Block types”Custom block-level elements inside the editor:
interface BlockContribution { type: string name: string component: ComponentType<{ node: unknown updateAttributes: (attrs: Record<string, unknown>) => void }>}Settings panels
Section titled “Settings panels”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 }>}Schema contributions
Section titled “Schema contributions”Register new schemas from a plugin:
interface SchemaContribution { schema: unknown // A defineSchema() result}React hooks
Section titled “React hooks”Use these hooks to consume plugin contributions in your components:
| Hook | Returns | Description |
|---|---|---|
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 | undefined | Find a specific view by type |
useCommand(id) | CommandContribution | undefined | Find a specific command by ID |
usePluginRegistry() | PluginRegistry | Direct registry access (throws if missing) |
All hooks are reactive — they re-render when contributions change.
Middleware
Section titled “Middleware”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}Example: audit log middleware
Section titled “Example: audit log middleware”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}`) } }})Middleware capabilities
Section titled “Middleware capabilities”- Pass through: Call
next()to continue the chain - Modify: Mutate the
changeobject before callingnext() - 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.
Layer 1: Scripts
Section titled “Layer 1: Scripts”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.
Script node
Section titled “Script node”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}Script context
Section titled “Script context”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 }}Example script
Section titled “Example script”// Compute a "progress" value from subtask completionconst tasks = nodes('xnet://app/Task')const done = array.count(tasks, (t) => t.status === 'done')const total = tasks.lengthtotal > 0 ? math.round((done / total) * 100) : 0AST validation
Section titled “AST validation”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(),withstatements
Sandbox execution
Section titled “Sandbox execution”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.
AI script generation
Section titled “AI script generation”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.
Layer 3: Services
Section titled “Layer 3: Services”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.
Layer 4: Integrations
Section titled “Layer 4: Integrations”Local HTTP API
Section titled “Local HTTP API”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/:idPATCH /api/v1/nodes/:idDELETE /api/v1/nodes/:idPOST /api/v1/query { schema, limit?, offset? }GET /api/v1/events?since= Polling endpointGET /api/v1/schemasOptional Bearer token authentication. CORS enabled.
MCP server
Section titled “MCP server”Exposes xNet data to AI agents via the Model Context Protocol:
| Tool | Description |
|---|---|
xnet_query | Query nodes by schema |
xnet_get | Get a node by ID |
xnet_create | Create a node |
xnet_update | Update node properties |
xnet_delete | Delete a node |
xnet_schemas | List all schemas |
The MCP server reads from stdin and writes JSON-RPC 2.0 responses to stdout.
Webhooks
Section titled “Webhooks”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.
Worked example: Mermaid plugin
Section titled “Worked example: Mermaid plugin”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 needcontributes - 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_PLUGINSin the app entry point and rehydrated on restart usingregistry.rehydrate()to restore live function references after deserialization
Permissions
Section titled “Permissions”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 }}Platform compatibility
Section titled “Platform compatibility”| Feature | Electron | Web | Mobile |
|---|---|---|---|
| Views | Yes | Yes | Yes |
| Commands | Yes | Yes | Yes |
| Slash commands | Yes | Yes | No |
| Editor extensions | Yes | Yes | No |
| Sidebar items | Yes | Yes | Yes |
| Property handlers | Yes | Yes | Yes |
| Block types | Yes | Yes | No |
| Settings panels | Yes | Yes | Yes |
| Scripts (sandbox) | Yes | Yes | Yes |
| Services (processes) | Yes | No | No |
| Local API | Yes | No | No |
| MCP server | Yes | No | No |
| Webhooks | Yes | Yes | Yes |
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.
Declared capabilities are enforced
Section titled “Declared capabilities are enforced”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.
Provenance, trust tier, and install consent
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.
Version compatibility and dependencies
Section titled “Version compatibility and dependencies”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 })Testing a plugin
Section titled “Testing a plugin”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)Scaffolding a new plugin
Section titled “Scaffolding a new plugin”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:
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 harnessAuthoring a plugin with AI
Section titled “Authoring a plugin with AI”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'Reaching the network safely
Section titled “Reaching the network safely”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') // okawait fetch('https://evil.example.com') // rejects: CapabilityErrorPublishing to the marketplace
Section titled “Publishing to the marketplace”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.
-
Ship a Release. Build your plugin into a single
plugin.jsand attach it, withmanifest.json, to a GitHub Release. (A copyable template with a release workflow lives inexamples/xnet-plugin-template.) -
Add one entry to
registry/community.json:[{ "repo": "you/xnet-plugin-kanban", "category": "views" }] -
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.