Skip to content

Plugin Best Practices

scarecr0w12 edited this page Jun 23, 2026 · 5 revisions

Plugin Best Practices

Core Principles

  1. Single Responsibility — Each plugin should do one thing well
  2. Graceful Failure — Always return a result from tool execute, never throw
  3. Input Validation — Validate all args before execution
  4. Respect Timeouts — Default tool timeout is 30 s; return early if possible
  5. Minimal Permissions — Declare only capabilities you actually use
  6. Clean Up — Unregister tools, cancel timers, close connections in onUnload

Tool Implementation

Always Return, Never Throw

// GOOD
execute: async (args) => {
  const start = Date.now();
  try {
    const result = await doWork(args);
    return { toolName: 'my_tool', success: true, output: result, durationMs: Date.now() - start };
  } catch (err) {
    return { toolName: 'my_tool', success: false, output: '',
             error: (err as Error).message, durationMs: Date.now() - start };
  }
},

// BAD — throws break the tool loop
execute: async (args) => {
  if (!args.input) throw new Error('input required'); // never do this
},

Validate Inputs First

execute: async (args) => {
  const url = args.url;
  if (typeof url !== 'string' || !url.startsWith('https://')) {
    return { toolName: 'fetch', success: false, output: '',
             error: 'url must be a valid https:// URL', durationMs: 0 };
  }
  // ...
},

Serialize Output to String

output must always be a string. Use JSON.stringify() for structured data:

return { toolName: 'search', success: true,
         output: JSON.stringify(results, null, 2), durationMs: Date.now() - start };

ESM Plugin Tips

  • Use ctx.state for persistence — in-memory module variables reset on reload
  • Use ctx.config for user-configurable values, never hardcode strings
  • Use ctx.logger instead of console.log for proper log prefixes
  • Register tools in onActivate, unregister in onDeactivate (not onLoad/onUnload)
  • For fire-and-forget background work: doSlowThing().catch(() => {}) — never let uncaught promises kill the host

MCP Plugin Tips

  • Return valid JSON-RPC 2.0 errors: { "error": { "code": -32000, "message": "..." } }
  • Set a server-side timeout — CortexPrism has its own 30 s tool timeout
  • Use config:schema capability to inject API keys via ctx.config — never hardcode
  • Declare all tools in the manifest so the agent's tool selection works correctly

WASM Plugin Tips

  • Write capabilities as UTF-8 JSON: plugin_get_capabilities writes [{"name":"...","description":"...","params":[...]}]
  • Always export memory from your module (required by the runtime)
  • Use set_state/get_state host functions — ctx.state is not directly accessible from WASM
  • Keep binary size under 10 MB for fast startup
  • Test outside CortexPrism first: wasmtime run plugin.wasm
  • Return 0 from host-interfacing functions on success, non-zero on error

Pipeline Hook Best Practices

Pipeline hooks can intercept the agent at 12 named stages. Mistakes here affect all users.

Always Guard on Stage

middlewarePre is called for every pipeline stage, not just pre-tool:

export const middlewarePre = async (ctx: unknown) => {
  const c = ctx as { stage: string; toolCall?: { name: string } };

  // ALWAYS check stage first
  if (c.stage !== 'pre-tool') return {};
  if (!c.toolCall) return {};

  // ... your logic
  return {};
};

Provide a Clear Abort Message

// GOOD
return { abort: { reason: 'policy', message: 'File access is blocked outside ~/projects/.' } };

// BAD — user sees nothing or a cryptic error
return { abort: { reason: 'denied', message: '' } };

Keep Hooks Fast

Sync hooks time out after 5 seconds. Offload slow work with fire-and-forget:

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

  // Fire-and-forget for slow external logging
  sendAuditLog(c.toolResult).catch(() => {});

  return {};
};

Use sideEffects for Structured Logging

return {
  sideEffects: [
    { type: 'store', payload: { key: 'lastTool', value: c.toolCall?.name } },
    { type: 'log', payload: { tool: c.toolCall?.name } },
  ],
};

Only Mutate Context When Necessary

Only return modifyInput, modifyLLMResponse, or modifyOutput when you have a real reason. Unnecessary mutations make debugging hard for the user.

Event Subscription Best Practices

Always Unsubscribe in onUnload

let handler: ((event: unknown) => void) | null = null;

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

export const onUnload = async (_ctx: PluginContext) => {
  if (handler) {
    globalEventBus.off('session:start', handler);
    handler = null;
  }
};

Failing to unsubscribe leaves dangling handlers that fire after the plugin is disabled.

Declare All Subscribed Events in the Manifest

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

Events not listed in events may be blocked by future security hardening.

Permission Best Practices

Declare Minimal Permissions

// GOOD
"capabilities": ["tools", "network:fetch"]

// BAD — requesting everything without need
"capabilities": ["tools", "fs:read", "fs:write", "fs:delete", "shell:run", "network:fetch"]

Match Tool Capabilities to Manifest

const myTool: Tool = {
  definition: {
    name: 'read_file',
    description: 'Read a file',
    params: [],
    capabilities: ['fs:read'],  // must be a subset of manifest capabilities
  },
  // ...
};

If a tool declares capabilities not present in the manifest, it is rejected at load time.

Sandbox-Safe Coding

If your plugin may run sandboxed (untrusted or signed trust level):

  • Do not use Deno.env — env access is restricted in the Worker
  • Do not use Deno.Command — requires shell:run capability
  • Do not call Deno.exit() — terminates the Worker, tools stop responding
  • Do not import from https:// at runtime without network:fetch
  • Test with minimal permissions — run with only the capabilities you declare

UI Panel Best Practices

  • Sanitize dynamic content — never render raw API data as HTML; prevents XSS
  • Use window.Cortex.getConfig() instead of hardcoded values
  • Use window.Cortex.notify() for user feedback — avoid alert() or browser dialogs
  • Handle fetch failureswindow.Cortex.fetch() can fail; always wrap in try/catch
  • Design for narrow widths — sidebar panels may be as narrow as 240 px
  • Never trust panel input — validate data from panels server-side if it drives actions

Lifecycle Discipline

Hook What to do What NOT to do
onInstall Write default state, log first install Make network calls
onLoad Subscribe to events, init in-memory state Register tools
onActivate Register tools, start polling Slow blocking initialization
onDeactivate Unregister tools, stop polling Modify persisted state
onUnload Unsubscribe events, close connections Throw — breaks the unload sequence
onUninstall Delete persisted state/config Assume onUnload already ran
onConfigChange Apply the new config value Block — called per-key on every change

Testing Checklist

# Install from local directory
cortex plugins install ./my-plugin

# Enable and verify active
cortex plugins enable my-plugin
cortex plugins list

# Test tools interactively
cortex agent chat

# Check effective permissions
cortex plugins permissions my-plugin

# Test config change
cortex config set plugins.my-plugin.apiKey test123

# Disable/re-enable (tests deactivate/activate cycle)
cortex plugins disable my-plugin
cortex plugins enable my-plugin

# Remove (tests onUninstall)
cortex plugins remove my-plugin

What to Avoid

  • Don't throw from execute() — it breaks the tool loop
  • Don't call Deno.exit() — kills the host process (trusted) or Worker (sandboxed)
  • Don't hardcode API keys — use ctx.config.get('apiKey') with a ui.settings field
  • Don't block onLoad — slow synchronous work freezes all plugin loading
  • Don't skip cleanup — failing to unregister tools or listeners causes leaks
  • Don't check toolCall without checking stage — it's only populated at pre-tool
  • Don't emit events in onUnload — the event bus may be partially torn down
  • Don't store large data in ctx.state — values are strings; store paths, not file contents

WASM-Specific Best Practices

  • Export plugin_get_abi_version — the host rejects plugins without it
  • Use host_alloc for dynamic memory — don't rely on fixed offsets; the host scratch area may shift
  • Return 0 from plugin_get_capabilities — non-zero aborts tool registration
  • Write full param schemas — LLMs can't use your tools without knowing their argument types
  • Keep plugin_execute_tool under 120 s — the host kills execution at this timeout
  • Return 0 on success — non-zero is treated as tool failure with output as error message
  • Call host_set_state early — state is written asynchronously; set it before tool execution ends
  • Don't import WASI system callsproc_exit, args_get, environ_get, sock_* are blocked by supply-chain scanning and won't work at runtime
  • Use host_random for cryptographic needsMath.random() equivalent is not available in WASM
  • Test with the SDKsrc/plugins/sdk/client.ts provides validation helpers for capabilities JSON

See Also

Clone this wiki locally