Skip to content

Developing Plugins

scarecr0w12 edited this page Jun 23, 2026 · 8 revisions

Developing Plugins

Complete guide to building CortexPrism plugins — tools, lifecycle hooks, pipeline hooks, UI panels, CLI commands, LLM providers, events, sandbox mode, and the window.Cortex panel API.

Project Structure

my-plugin/
├── manifest.json        # Plugin identity, capabilities, entry point
├── mod.ts               # Entry point — exports tools, hooks, providers
├── tools/
│   └── my_tool.ts       # Tool implementations (optional, can inline)
├── ui/
│   ├── panel.html       # Web UI panel HTML
│   └── panel.js         # Web UI panel JS (optional)
└── README.md

Manifest (manifest.json)

Every plugin requires a manifest.json at its root.

{
  "name": "my-plugin",
  "version": "1.0.0",
  "description": "An example plugin",
  "kind": "esm",
  "entryPoint": "./mod.ts",
  "runtime": "deno",
  "capabilities": ["tools", "network:fetch"],
  "author": "acme",
  "homepage": "https://example.com",
  "license": "MIT",
  "repository": "https://github.com/acme/my-plugin",
  "dependencies": { "other-plugin": "^1.0.0" },
  "events": ["session:start", "llm:post-call"],
  "tools": [
    {
      "name": "my_tool",
      "description": "Does something useful",
      "params": [
        { "name": "input", "type": "string", "description": "Input value", "required": true },
        { "name": "mode", "type": "string", "description": "Mode", "required": false,
          "enum": ["fast", "thorough"] }
      ]
    }
  ],
  "cliCommands": [
    {
      "name": "my-cmd",
      "description": "A CLI subcommand",
      "options": [
        { "name": "verbose", "type": "boolean", "description": "Verbose output", "flag": "-v" }
      ]
    }
  ],
  "ui": {
    "panels": [
      { "id": "main", "title": "My Panel", "icon": "star", "htmlPath": "./ui/panel.html" }
    ],
    "widgets": [
      { "id": "stats", "title": "Stats", "type": "html", "config": {} }
    ],
    "settings": [
      {
        "section": "API",
        "fields": [
          { "key": "apiKey", "label": "API Key", "type": "secret", "defaultValue": "",
            "description": "Your service API key" },
          { "key": "maxResults", "label": "Max Results", "type": "number", "defaultValue": 10 },
          { "key": "mode", "label": "Mode", "type": "select", "defaultValue": "fast",
            "options": [{ "label": "Fast", "value": "fast" }, { "label": "Thorough", "value": "thorough" }] }
        ]
      }
    ]
  },
  "config": {
    "providers": [
      { "kind": "my-provider", "label": "My LLM Provider", "defaultModel": "my-model-v1" }
    ]
  }
}

See Manifest Reference for every field in detail.

Entry Point (mod.ts)

The loader reads these named exports from your entry point. All are optional.

import type { Tool, PluginContext, CliCommandDeclaration } from 'cortex/plugins';

// --- Tools ---
export const tools: Tool[] = [];

// --- CLI subcommands (requires cli:commands capability) ---
export const cliCommands: CliCommandDeclaration[] = [];

// --- LLM provider factories (requires config:provider capability) ---
export const providers: Record<string, (config: Record<string, unknown>) => unknown> = {};

// --- Lifecycle hooks ---
export const onInstall   = async (ctx: PluginContext) => {};
export const onLoad      = async (ctx: PluginContext) => {};
export const onActivate  = async (ctx: PluginContext) => {};
export const onDeactivate = async (ctx: PluginContext) => {};
export const onUnload    = async (ctx: PluginContext) => {};
export const onUninstall = async (ctx: PluginContext) => {};
export const onConfigChange = async (key: string, value: unknown, ctx: PluginContext) => {};

// --- Pipeline hooks (requires middleware:pre / middleware:post capability) ---
export const middlewarePre  = async (ctx: unknown) => ({});
export const middlewarePost = async (ctx: unknown) => ({});

Lifecycle Hooks

Each hook receives a PluginContext. All are optional exports.

// Called once when the plugin is first installed
export const onInstall = async (ctx: PluginContext) => {
  await ctx.state.set('installedAt', new Date().toISOString());
};

// Called each time the module is imported into memory
export const onLoad = async (ctx: PluginContext) => {
  ctx.logger.info('Plugin loaded');
};

// Called after tools are registered and events subscribed (plugin is now "live")
export const onActivate = async (ctx: PluginContext) => {
  ctx.logger.info('Plugin active');
};

// Called before tools are unregistered (plugin about to go offline)
export const onDeactivate = async (ctx: PluginContext) => {};

// Called when the module is unloaded from memory — clean up intervals, connections
export const onUnload = async (ctx: PluginContext) => {};

// Called when the plugin is permanently removed — clean up persisted data
export const onUninstall = async (ctx: PluginContext) => {
  await ctx.state.delete('installedAt');
};

// Called each time a single config key changes at runtime
export const onConfigChange = async (key: string, value: unknown, ctx: PluginContext) => {
  ctx.logger.info(`Config "${key}" → ${value}`);
};

Lifecycle Sequence

Install:     onInstall → onLoad → onActivate
Enable:      onLoad → onActivate
Disable:     onDeactivate → onUnload
Remove:      onDeactivate → onUnload → onUninstall
Reconfigure: onConfigChange (per key, non-blocking)

PluginContext API

Every lifecycle hook and tool execute receives a PluginContext.

ctx.state — Persistent Key-Value Store

Survives plugin reloads and host restarts. Values are always strings.

await ctx.state.set('counter', '0');
const counter = await ctx.state.get('counter');   // string | null
const all     = await ctx.state.list();           // Record<string, string>
await ctx.state.delete('counter');

ctx.config — Typed Config Access

Reads from ~/.cortex/config.json under plugins.<name>.<key>.

const apiKey    = await ctx.config.get<string>('apiKey');    // T | null
const maxRetries = await ctx.config.get<number>('maxRetries');
const all       = await ctx.config.getAll();                  // Record<string, unknown>
await ctx.config.set('lastInit', Date.now());

ctx.logger — Scoped Logger

Prefixes every message with [plugin:<name>].

ctx.logger.info('Operation completed');
ctx.logger.warn('Rate limit approaching');
ctx.logger.error('Failed to connect');
ctx.logger.debug('Payload:', { key: 'value' });

ctx.host — Dynamic Tool Registration

Register or unregister tools at runtime (e.g. based on config).

export const onActivate = async (ctx: PluginContext) => {
  ctx.host.registerTool({
    definition: {
      name: 'dynamic_tool',
      description: 'Created at runtime',
      params: [],
      capabilities: [],
    },
    execute: async () => ({
      toolName: 'dynamic_tool',
      success: true,
      output: 'Hello from dynamic tool',
      durationMs: 0,
    }),
  });
};

export const onDeactivate = async (ctx: PluginContext) => {
  ctx.host.unregisterTool('dynamic_tool');
};

ctx.pluginId and ctx.pluginDir

ctx.pluginId   // Plugin name string, e.g. "my-plugin"
ctx.pluginDir  // Absolute path to ~/.cortex/data/plugins/my-plugin/

Tool Implementation

Tool Interface

interface Tool {
  definition: {
    name: string;
    description: string;
    params: {
      name: string;
      type: string;          // 'string' | 'number' | 'boolean' | 'object' | 'array'
      description: string;
      required?: boolean;
      enum?: string[];       // restrict to allowed values
    }[];
    capabilities: string[];  // per-tool permissions, subset of manifest capabilities
  };
  execute(args: Record<string, unknown>, ctx: ToolContext): Promise<ToolCallResult>;
}

interface ToolCallResult {
  toolName: string;
  success: boolean;
  output: string;           // always a string — JSON.stringify objects
  error?: string;           // set when success is false
  durationMs: number;
}

Tool Best Practices

const myTool: Tool = {
  definition: {
    name: 'fetch_data',
    description: 'Fetch data from a URL',
    params: [
      { name: 'url', type: 'string', description: 'URL to fetch', required: true },
    ],
    capabilities: ['network:fetch'],
  },
  execute: async (args, ctx) => {
    const start = Date.now();

    // 1. Validate inputs first
    if (!args.url || typeof args.url !== 'string') {
      return { toolName: 'fetch_data', success: false, output: '',
               error: 'url is required', durationMs: 0 };
    }

    // 2. Never throw — always return a result
    try {
      const res = await fetch(args.url);
      const text = await res.text();
      return { toolName: 'fetch_data', success: true, output: text,
               durationMs: Date.now() - start };
    } catch (err) {
      return { toolName: 'fetch_data', success: false, output: '',
               error: (err as Error).message, durationMs: Date.now() - start };
    }
  },
};

Pipeline Hooks

Plugins can intercept the agent execution pipeline at 12 named stages. Export middlewarePre and/or middlewarePost from your module and declare the matching capability.

Pipeline Stages

Stage When Available context fields
pre-assess Before metacognitive assessment input, messages
post-assess After assessment assessment, messages
pre-reason Before reasoning loop input, messages
post-reason After reasoning loop output
pre-tool Before each tool call toolCall
post-tool After each tool call toolCall, toolResult
pre-llm Before LLM API call messages
post-llm After LLM API call currentLLMResponse
pre-reflect Before reflection output
post-reflect After reflection reflection
pre-output Before final response output
post-output After final response output

middlewarePre hooks run at pre-tool. middlewarePost hooks run at post-tool.

HookResult Fields

interface HookResult {
  abort?: { reason: string; message: string }; // abort the turn — message shown to user
  modifyInput?: string;        // replace user input
  modifyLLMResponse?: string;  // replace raw LLM response text
  modifyOutput?: string;       // replace final output
  injectMessages?: Message[];  // append to conversation history
  sideEffects?: Array<
    | { type: 'log'; payload: unknown }
    | { type: 'metric'; payload: unknown }
    | { type: 'store'; payload: { key: string; value: unknown } }
    | { type: 'notify'; payload: unknown }
  >;
}

Pre-tool Hook — Block Dangerous Tools

export const middlewarePre = async (ctx: unknown) => {
  const c = ctx as { stage: string; toolCall?: { name: string } };
  if (c.stage !== 'pre-tool' || !c.toolCall) return {};

  const BLOCKED = ['file_delete', 'shell_run'];
  if (BLOCKED.includes(c.toolCall.name)) {
    return {
      abort: {
        reason: 'policy',
        message: `Tool "${c.toolCall.name}" is blocked by the security plugin.`,
      },
    };
  }

  return {};
};

Post-tool Hook — Audit Logging

export const middlewarePost = async (ctx: unknown) => {
  const c = ctx as {
    stage: string;
    toolResult?: { toolName: string; durationMs: number; success: boolean };
  };
  if (c.stage !== 'post-tool' || !c.toolResult) return {};

  console.log(
    `[audit] ${c.toolResult.toolName}${c.toolResult.success ? 'ok' : 'fail'} ` +
    `(${c.toolResult.durationMs}ms)`
  );
  return {};
};

Sync hooks time out after 5 s. Set async: true in your HookResult for up to 15 s. Hooks from middlewarePre/middlewarePost run at priority: 50.

Event Subscriptions

Declare events:listener in capabilities, list event types in the manifest events field, then subscribe in onLoad.

Manifest

{
  "capabilities": ["events:listener"],
  "events": ["session:start", "session:end", "tool:post-execute", "llm:post-call"]
}

Subscribing in Code

import { globalEventBus } from 'cortex/plugins';

export const onLoad = async (ctx: PluginContext) => {
  globalEventBus.on('session:start', (event) => {
    ctx.logger.info(`Session started: ${(event as { sessionId: string }).sessionId}`);
  });

  globalEventBus.on('tool:post-execute', (event) => {
    const e = event as { toolName: string; result: unknown };
    ctx.logger.debug(`Tool ${e.toolName} completed`);
  });

  globalEventBus.on('llm:post-call', (event) => {
    const e = event as { provider: string; model: string; tokensIn: number; tokensOut: number };
    ctx.logger.debug(`LLM ${e.provider}/${e.model}: ${e.tokensIn}${e.tokensOut} tokens`);
  });
};

Event Payload Reference

Event Payload fields
session:start { sessionId: string }
session:end { sessionId: string }
tool:pre-execute { toolName: string, args: Record<string, unknown> }
tool:post-execute { toolName: string, result: unknown }
llm:pre-call { provider: string, model: string }
llm:post-call { provider: string, model: string, tokensIn: number, tokensOut: number }
agent:turn-start { sessionId: string, turnId: string }
agent:turn-end { sessionId: string, turnId: string, response: string }
config:change { key: string, value: unknown }
daemon:status { daemon: string, status: 'up' | 'down' }

Unsubscribe by calling globalEventBus.off(type, handler) in onUnload.

Extension Points

CLI Commands (cli:commands)

Export a handler function and declare the command in the manifest.

// mod.ts
export async function myCommand(args: Record<string, unknown>) {
  const verbose = args.verbose as boolean;
  console.log('Running:', args, verbose ? '(verbose)' : '');
}
{
  "capabilities": ["cli:commands"],
  "cliCommands": [
    {
      "name": "my-cmd",
      "description": "My plugin CLI command",
      "args": [
        { "name": "target", "type": "string", "description": "Target name", "required": true }
      ],
      "options": [
        { "name": "verbose", "type": "boolean", "description": "Verbose output", "flag": "-v" }
      ]
    }
  ]
}

Available as cortex my-cmd <target> [-v] after enabling.

LLM Providers (config:provider)

// mod.ts
export const providers = {
  'my-llm': (config: Record<string, unknown>) => ({
    name: 'my-llm',
    defaultModel: 'my-model-v1',
    async complete(opts: unknown) {
      // Return { content: string, tokensIn: number, tokensOut: number }
    },
    async *stream(opts: unknown) {
      // Yield { delta: string } chunks
    },
  }),
};
{
  "capabilities": ["config:provider"],
  "config": {
    "providers": [
      { "kind": "my-llm", "label": "My LLM", "defaultModel": "my-model-v1" }
    ]
  }
}

UI Panels (ui:panel)

Declare panels in the manifest. Panels are served as iframes with the window.Cortex API injected.

manifest.json:

{
  "capabilities": ["ui:panel"],
  "ui": {
    "panels": [
      { "id": "dashboard", "title": "My Dashboard", "icon": "bar-chart", "htmlPath": "./ui/panel.html" }
    ]
  }
}

ui/panel.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>My Dashboard</title>
  <style>
    body { font-family: system-ui; padding: 1rem; background: #0a0a0f; color: #e2e2ea; }
  </style>
</head>
<body>
  <h2>My Plugin</h2>
  <div id="output"></div>
  <script>
    // window.Cortex is injected by the host — no import needed
    window.Cortex.getConfig('apiKey').then(key => {
      document.getElementById('output').textContent = key ? 'Key configured' : 'No key set';
    });
  </script>
</body>
</html>

UI Settings (ui:panel or ui:widget)

Declare settings fields in the manifest ui.settings array. They appear in the plugin settings UI.

{
  "ui": {
    "settings": [
      {
        "section": "Connection",
        "fields": [
          { "key": "apiKey",     "label": "API Key",      "type": "secret",  "defaultValue": "" },
          { "key": "endpoint",   "label": "Endpoint URL", "type": "text",    "defaultValue": "https://api.example.com" },
          { "key": "timeout",    "label": "Timeout (ms)", "type": "number",  "defaultValue": 5000 },
          { "key": "verify",     "label": "Verify SSL",   "type": "boolean", "defaultValue": true },
          { "key": "region",     "label": "Region",       "type": "select",  "defaultValue": "us-east",
            "options": [{ "label": "US East", "value": "us-east" }, { "label": "EU West", "value": "eu-west" }] }
        ]
      }
    ]
  }
}

Setting types: text | number | boolean | select | secret

Middleware (pre/post tool intercept)

See Pipeline Hooks above.

window.Cortex Panel API

Injected into every plugin panel iframe by the CortexPrism host:

// Authenticated fetch — path relative to /api/plugins/<name>/ or absolute
const res  = await window.Cortex.fetch('config');
const res2 = await window.Cortex.fetch('/api/sessions');

// Config access
const key = await window.Cortex.getConfig('apiKey');
await window.Cortex.setConfig('theme', 'dark');

// Events
window.Cortex.onEvent('my-event', (data) => console.log(data));
window.Cortex.emit('panel:ready', { ts: Date.now() });

// Notifications (shown in the host UI)
window.Cortex.notify('Saved!', 'info');   // 'info' | 'warn' | 'error'

postMessage Protocol

Direct postMessage to the parent frame:

// Emit a named event
window.parent.postMessage({
  type: 'cortex-event',
  pluginName: 'my-plugin',
  event: 'my-event',
  data: { key: 'value' }
}, '*');

// Show notification
window.parent.postMessage({
  type: 'cortex-notification',
  pluginName: 'my-plugin',
  notification: { msg: 'Done!', type: 'info' }
}, '*');

Plugin Commands

Structured commands panels can send to the host:

type Required fields Effect
navigate to: string Navigate host UI to route
open-modal title: string, content: string Open host modal
notification text: string, level: string Show notification
config-get key: string Read a config value
config-set key: string, value: unknown Write a config value
query query: string Execute a query

Sandbox Mode

Plugins with trust_level of untrusted or signed run in a Deno Worker sandbox with restricted permissions. Only trusted plugins (verified supply-chain) run in-process.

Capability → Deno Permission Mapping

Capability Deno Worker permission
fs:read, fs:list read: true
fs:write, fs:edit, fs:delete write: true
shell:run run: true
network:fetch, net:outbound, net:inbound net: true

Writing a Sandbox-Compatible Entry Point

The sandbox expects your worker to post { type: 'ready' } on startup and respond to two RPC methods:

// sandbox-mod.ts — loaded as Deno Worker
import { tools } from './tools.ts';

// Signal readiness
self.postMessage({ type: 'ready' });

self.onmessage = async (ev) => {
  const { id, method, params } = ev.data;

  if (method === 'getTools') {
    self.postMessage({ id, result: tools.map(t => t.definition) });
    return;
  }

  if (method === 'executeTool') {
    const tool = tools.find(t => t.definition.name === params.toolName);
    if (!tool) {
      self.postMessage({ id, error: { message: `Unknown tool: ${params.toolName}` } });
      return;
    }
    try {
      const result = await tool.execute(params.args, {});
      self.postMessage({ id, result });
    } catch (e) {
      self.postMessage({ id, error: { message: (e as Error).message } });
    }
    return;
  }

  self.postMessage({ id, error: { message: `Unknown method: ${method}` } });
};

Timeout: The sandbox must post { type: 'ready' } within 30 seconds or the worker is terminated and the plugin fails to load with an error.

Plugin Namespacing

Marketplace plugins use @author/name scoping:

@acme/weather     →  author=acme, name=weather
my-plugin         →  author=unknown, name=my-plugin (auto-resolved)

Tool names from namespaced plugins follow @author/plugin-name/tool_name.

MCP Plugin Development

MCP plugins wrap an external JSON-RPC 2.0 server. CortexPrism creates a synthetic tool mcp_<name> that proxies calls to the server.

{
  "name": "my-mcp",
  "kind": "mcp",
  "entryPoint": "https://my-server.example.com/rpc",
  "runtime": "deno",
  "capabilities": ["tools", "network:fetch"],
  "tools": [
    { "name": "search", "description": "Search", "params": [
      { "name": "query", "type": "string", "description": "Query", "required": true }
    ]}
  ]
}

The host sends:

{ "jsonrpc": "2.0", "id": 1, "method": "search", "params": { "query": "..." } }

The server must respond:

{ "jsonrpc": "2.0", "id": 1, "result": { ... } }

WASM Plugin Development

WASM plugins compile to .wasm (WebAssembly) and run in a dedicated worker sandbox with synchronous host functions. Any language that compiles to WASM is supported (C, Rust, Zig, Go, AssemblyScript).

ABI Versioning

WASM plugins must export plugin_get_abi_version() returning the ABI version they support. The host checks this on load and rejects incompatible versions. Current host ABI version: 1.

int32_t plugin_get_abi_version(void) { return 1; }

Required Exports

Export Signature Required Purpose
plugin_get_abi_version () → i32 Yes Return supported ABI version
plugin_init () → void No Called once on load
plugin_destroy () → void No Called on unload — free resources
plugin_get_capabilities (outPtr, outLenPtr) → i32 Yes Return JSON capabilities
plugin_execute_tool (namePtr, nameLen, argsPtr, argsLen, outPtr, outLenPtr) → i32 Yes Execute a tool
memory WebAssembly.Memory Yes Shared linear memory (must be exported)

Capabilities JSON Format

plugin_get_capabilities must return a JSON object with full parameter schemas:

{
  "abi_version": 1,
  "tools": [
    {
      "name": "my_tool",
      "description": "Does something useful",
      "params": [
        { "name": "input", "type": "string", "description": "The input", "required": true },
        { "name": "limit", "type": "number", "description": "Max results", "required": false }
      ]
    }
  ]
}

Parameter types: string | number | boolean | object | array. The host uses this schema to describe tool parameters to LLMs, so WASM tools are fully understood by agents.

Memory Layout

[0x000000 - 0x00FFFF]  Host scratch area (64 KB) — do not use
[0x010000 - 0x01FFFF]  Host allocator metadata (64 KB) — do not use
[0x020000 - 0x0FFFFF]  Host-managed heap (896 KB) — use via host_alloc/host_free
[0x100000 - ...]       Your memory — start data here (__heap_base = 0x100000)

Default memory: 256 initial pages (16 MiB), 512 max pages (32 MiB).

Host Functions (import env.*)

All strings are UTF-8 in linear memory. Return 0 for success, non-zero for error. Parameters are (pointer, length) pairs. Output is written to outPtr/outLenPtr.

Function Signature Description
host_alloc (size: i32) → i32 Bump-allocate from host-managed heap. Returns aligned pointer or 0.
host_free (ptr: i32) → void Free allocation (current no-op, call for forward compatibility).
host_log (ptr: i32, len: i32) → void Log a message to the CortexPrism log system.
host_get_config (keyPtr, keyLen, outPtr, outLenPtr) → i32 Read config from CORTEX_PLUGIN_{NAME}_{KEY} or CORTEX_WASM_{KEY} env vars.
host_set_state (keyPtr, keyLen, valPtr, valLen) → void Persist key-value state (in-memory cache + async SQLite flush).
host_get_state (keyPtr, keyLen, outPtr, outLenPtr) → i32 Read persisted state. Returns -1 if not found.
host_http_request (methodPtr, methodLen, urlPtr, urlLen, bodyPtr, bodyLen, headersPtr, headersLen, outStatusPtr, outBodyPtr, outBodyLenPtr) → i32 Synchronous HTTP request (via Worker + SharedArrayBuffer + Atomics.wait). 30 s timeout. Requires network:fetch or net:outbound capability.
host_get_abi_version () → i32 Returns host ABI version (currently 1).
host_get_time_ms () → i64 Current time in milliseconds since Unix epoch.
host_random (outPtr: i32, len: i32) → void Fill len bytes with cryptographically random data.

Permission Enforcement

host_http_request is gated on the network:fetch or net:outbound capability declared in the plugin manifest. If the capability is not present, the request returns HTTP 403. WASM plugins execute in a dedicated Worker and have no direct filesystem, shell, or database access — only the host functions above.

Execution Timeouts

Each tool execution is limited to 120 seconds. HTTP requests within tool execution have a 30-second individual timeout. The timeout guards against infinite loops and runaway memory allocation.

State Persistence

host_set_state/host_get_state use an in-memory cache backed by SQLite. State survives plugin reloads and host restarts. The cache is flushed to the plugin_state table asynchronously.

SDK

A C-compatible header is available at src/plugins/sdk/wasm-plugin.h declaring all host functions, memory layout constants (CORTEX_HEAP_BASE, CORTEX_SCRATCH_SIZE), and required exports.

A TypeScript helper library at src/plugins/sdk/client.ts provides defineTool(), definePlugin(), generateCapabilitiesJson(), and generateWasmPluginModule() for building WASM plugin code.

Example (C with wasm-plugin.h)

#include "wasm-plugin.h"

static char result_buffer[65536];

int32_t plugin_get_abi_version(void) { return 1; }

void plugin_init(void) {
  host_log("Hello from WASM plugin!", 23);
}

int32_t plugin_get_capabilities(char* out, uint32_t* outLen) {
  const char* json = "{\"abi_version\":1,\"tools\":[{\"name\":\"echo\","
    "\"description\":\"Echoes input\",\"params\":[{\"name\":\"msg\","
    "\"type\":\"string\",\"description\":\"Message\",\"required\":true}]}]}";
  uint32_t len = strlen(json);
  memcpy(out, json, len);
  *outLen = len;
  return 0;
}

int32_t plugin_execute_tool(
    const char* namePtr, uint32_t nameLen,
    const char* argsPtr, uint32_t argsLen,
    char* outPtr, uint32_t* outLen) {
  // Parse JSON args, execute tool, write result
  const char* result = "{\"echo\":\"hello\"}";
  uint32_t len = strlen(result);
  memcpy(outPtr, result, len);
  *outLen = len;
  return 0;
}

void plugin_destroy(void) {
  // Clean up resources
}

Lifecycle

Load:     fetch WASM → instantiate → plugin_get_abi_version → plugin_init → plugin_get_capabilities
Execute:  plugin_execute_tool (per agent tool call)
Unload:   plugin_destroy → free WebAssembly.Memory → clear state cache

WASM lifecycle is fully managed — plugin_destroy is called automatically when the plugin is disabled or removed. State cache is cleaned up on unload.

Supply-Chain Verification

Every plugin installation triggers an integrity check:

Check Description
SHA-256 hash Compared against known-good hash list
Blocked hash Known-malicious hashes are rejected outright
Digital signature Optional GPG signature validation
Author reputation Score 0–100; blocked/allowed author lists
Malware scan 6 default patterns: eval, child_process, rm -rf /, curl|sh, wget|sh

Trust level is derived from the result:

  • Verification passes → trusted (runs in-process)
  • No hash match → signed (sandboxed)
  • Suspicious / blocked → untrusted (sandboxed with minimal permissions)

Re-scan via: POST /api/plugins/:name/verification

Permission Overrides

Admins can grant or deny specific capabilities beyond what the manifest declares:

POST /api/plugins/:name/permissions
{ "permission_path": "shell:run", "action": "deny",  "value": "" }
{ "permission_path": "fs:read",   "action": "grant", "value": "" }

Effective permissions = declared − denied + granted. These govern sandbox Deno Worker permissions.

Testing

# Install locally (entry point must be absolute or file:// URL after resolution)
cortex plugins install ./my-plugin/manifest.json

# Enable
cortex plugins enable my-plugin

# Verify it loaded
cortex plugins list

# Test in a chat session
cortex agent chat

# View permission details
cortex plugins permissions my-plugin

# Push an update after changes
cortex plugins update my-plugin

Debugging

Symptom Cause / Fix
Status: error Run cortex plugins list — check error_message. Common: bad entry point, missing export, import error
Tool not visible Plugin must be active; tool must be in tools export
Permission denied Tool capabilities must be a subset of manifest capabilities
UI panel missing Declare ui:panel; verify htmlPath is relative to manifest and file exists
Events not firing Declare events:listener AND list event types in manifest events field
Sandbox not ready Worker must post { type: 'ready' } within 30 s
Config not reading Key must match what was set via ctx.config.set() or ~/.cortex/config.json under plugins.<name>

Plugin data: ~/.cortex/data/plugins/<name>/

See Also

Clone this wiki locally