- Deno 2.x
- A
manifest.jsonfile - An ESM entry point module (for ESM plugins)
my-plugin/
├── manifest.json # Plugin identity, capabilities, entry point
├── mod.ts # Entry point — exports tools, hooks, providers
├── tools/
│ └── my_tool.ts # Tool implementation
├── ui/
│ ├── panel.html # Web UI panel HTML
│ └── panel.js # Web UI panel JS
└── README.md # (optional) documentation
Create a manifest.json at the root of your plugin:
{
"name": "my-plugin",
"version": "1.0.0",
"description": "An example plugin that adds a weather tool and UI panel",
"kind": "esm",
"entryPoint": "./mod.ts",
"runtime": "deno",
"capabilities": ["tools", "ui:panel", "network:fetch"],
"tools": [
{
"name": "get_weather",
"description": "Get current weather for a city",
"params": [
{ "name": "city", "type": "string", "description": "City name", "required": true }
]
}
],
"ui": {
"panels": [
{ "id": "weather", "title": "Weather", "icon": "cloud", "htmlPath": "./ui/panel.html" }
],
"settings": [
{
"section": "API",
"fields": [
{ "key": "apiKey", "label": "Weather API Key", "type": "secret", "defaultValue": "" }
]
}
]
}
}See the Manifest Reference for every available field.
Create mod.ts as your entry point. The module exports tools, lifecycle hooks, and providers that the plugin loader picks up by name.
import type { Tool, PluginContext } from 'cortex/plugins';
const weatherTool: Tool = {
definition: {
name: 'get_weather',
description: 'Get current weather for a city',
params: [
{ name: 'city', type: 'string', description: 'City name', required: true },
],
capabilities: ['network:fetch'],
},
execute: async (args, ctx) => {
const apiKey = await ctx.config.get<string>('apiKey');
const res = await fetch(`https://api.weather.example/${args.city}?key=${apiKey}`);
const data = await res.text();
return {
toolName: 'get_weather',
success: true,
output: data,
durationMs: 0,
};
},
};
export const tools = [weatherTool];
// Lifecycle hooks (optional)
export const onLoad = async (ctx: PluginContext) => {
ctx.logger.info('Weather plugin loaded');
await ctx.state.set('startedAt', new Date().toISOString());
};
export const onUnload = async (ctx: PluginContext) => {
ctx.logger.info('Weather plugin unloaded');
};Restrict string parameters to a set of allowed values using enum:
const searchTool: Tool = {
definition: {
name: 'search_docs',
description: 'Search documentation',
params: [
{ name: 'query', type: 'string', description: 'Search query', required: true },
{ name: 'source', type: 'string', description: 'Documentation source', required: true, enum: ['internal', 'external', 'all'] },
{ name: 'limit', type: 'number', description: 'Max results', required: false },
],
capabilities: ['network:fetch'],
},
execute: async (args, ctx) => {
// ...
},
};Your entry point module can export these hooks. Each receives a PluginContext.
import type { PluginContext, ToolCallResult } from 'cortex/plugins';
// Called after manifest is stored, editable assets are staged
export const onInstall = async (ctx: PluginContext) => {
ctx.logger.info('Plugin installed');
await ctx.state.set('installedAt', new Date().toISOString());
};
// Called when the module is first imported into memory
export const onLoad = async (ctx: PluginContext) => {
ctx.logger.info('Plugin module loaded');
};
// Called when plugin transitions to active (tools registered, providers bound, events subscribed)
export const onActivate = async (ctx: PluginContext) => {
ctx.logger.info('Plugin activated — tools and events are live');
};
// Called when plugin begins disabling (before tools are unregistered)
export const onDeactivate = async (ctx: PluginContext) => {
ctx.logger.info('Plugin deactivating');
};
// Called when module is being unloaded from memory
export const onUnload = async (ctx: PluginContext) => {
ctx.logger.info('Plugin unloaded');
};
// Called when plugin is permanently removed (database row deleted, files cleaned)
export const onUninstall = async (ctx: PluginContext) => {
ctx.logger.info('Plugin uninstalled — cleaning up');
await ctx.state.delete('installedAt');
};
// Called when a single config value changes at runtime
export const onConfigChange = async (key: string, value: unknown, ctx: PluginContext) => {
ctx.logger.info(`Config key "${key}" changed to ${value}`);
};Install: onInstall → onLoad → onActivate
Enable: onLoad → onActivate
Disable: onDeactivate → onUnload
Remove: onDeactivate → onUnload → onUninstall
Reconfigure: onConfigChange (per key)
The PluginContext provides everything a plugin needs to interact with the host.
Persistent key-value store scoped to your plugin. Survives plugin reloads and host restarts.
export const onLoad = async (ctx: PluginContext) => {
// Store state
await ctx.state.set('counter', '0');
await ctx.state.set('lastRun', new Date().toISOString());
// Read state
const counter = await ctx.state.get('counter');
const allState = await ctx.state.list();
// Delete state
await ctx.state.delete('counter');
};Typed access to plugin config values from ~/.cortex/config.json. Supports generics for type-safe access.
export const onLoad = async (ctx: PluginContext) => {
const apiKey = await ctx.config.get<string>('apiKey');
const maxRetries = await ctx.config.get<number>('maxRetries');
const allConfig = await ctx.config.getAll();
// Set config programmatically
await ctx.config.set('lastInit', Date.now());
};Scoped logger that prefixes messages with [plugin:<name>].
ctx.logger.info('Operation completed');
ctx.logger.warn('Rate limit approaching');
ctx.logger.error('Failed to connect');
ctx.logger.debug('Request payload:', { city: 'London' });Register or unregister tools dynamically at runtime.
export const onActivate = async (ctx: PluginContext) => {
// Register a new tool programmatically
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');
};Subscribe to the CortexPrism event bus to react to sessions, tool calls, LLM calls, and agent turns.
{
"capabilities": ["events:listener"],
"events": ["session:start", "session:end", "tool:post-execute", "llm:post-call"]
}import { globalEventBus } from 'cortex/plugins';
import type { PluginContext, PluginEvent } from 'cortex/plugins';
let sessionCount = 0;
export const onLoad = async (ctx: PluginContext) => {
globalEventBus.on('session:start', (event) => {
sessionCount++;
ctx.logger.info(`Session ${event.sessionId} started (total: ${sessionCount})`);
});
globalEventBus.on('session:end', (event) => {
ctx.logger.info(`Session ${event.sessionId} ended`);
});
globalEventBus.on('tool:post-execute', (event) => {
ctx.logger.info(`Tool ${event.toolName} completed`);
// Can inspect event.result for tool output
});
globalEventBus.on('llm:post-call', (event) => {
ctx.logger.info(
`LLM call to ${event.provider}/${event.model}: ${event.tokensIn} in, ${event.tokensOut} out`
);
});
};| Event | Payload |
|---|---|
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' } |
Export tools from your module. Each tool has a definition and an execute function.
import type { Tool, ToolCallResult } from 'cortex/plugins';
const myTool: Tool = {
definition: {
name: 'my_tool',
description: 'Does something useful',
params: [
{ name: 'input', type: 'string', description: 'Input value', required: true },
{ name: 'verbose', type: 'boolean', description: 'Verbose output', required: false },
],
capabilities: ['network:fetch'], // per-tool permissions
},
execute: async (args, ctx) => {
const start = Date.now();
try {
const result = await doWork(args.input);
return {
toolName: 'my_tool',
success: true,
output: JSON.stringify(result),
durationMs: Date.now() - start,
};
} catch (err) {
return {
toolName: 'my_tool',
success: false,
output: '',
error: err.message,
durationMs: Date.now() - start,
};
}
},
};Tool execution best practices:
- Track
durationMsaccurately - Return
success: falsewitherrormessage on failure - Don't throw — return failure results instead
- Keep
outputas a string (serialize objects with JSON.stringify) - Declare per-tool
capabilitiesmatching what the tool actually needs
Declare commands in the manifest and export a handler function:
// mod.ts
export async function myCommand(args: Record<string, unknown>) {
console.log(`Running with args:`, args);
}// manifest.json
{
"cliCommands": [
{
"name": "my-command",
"description": "My custom CLI command",
"options": [
{ "name": "verbose", "type": "boolean", "description": "Verbose output", "flag": "-v" }
]
}
]
}CLI commands are accessible as cortex <command-name> after the plugin is enabled.
Export providers from your module. Each provider factory receives the plugin's config and returns a provider instance:
export const providers = {
'my-provider': (config: Record<string, unknown>) => ({
name: 'my-provider',
defaultModel: 'my-model',
async complete(opts) { /* ... */ },
async *stream(opts) { /* ... */ },
}),
};Providers are registered when the plugin activates and become available for agent LLM calls.
Define panels in the manifest under ui.panels. Each panel has an HTML file and optional JS file served by the CortexPrism server.
manifest.json:
{
"ui": {
"panels": [
{ "id": "my-panel", "title": "My Panel", "icon": "star", "htmlPath": "./ui/panel.html" }
]
}
}ui/panel.html:
<!DOCTYPE html>
<html>
<head><title>My Panel</title></head>
<body>
<h1>My Plugin Panel</h1>
<div id="output"></div>
<script src="./panel.js"></script>
</body>
</html>Panel JS files can use postMessage to communicate with the host via the CortexUiApi.
Plugins can intercept tool execution with pre and post middleware.
Pre-execution middleware — runs before any tool executes:
export const preMiddleware = async (
toolName: string,
args: Record<string, unknown>,
ctx: PluginContext
) => {
ctx.logger.info(`About to execute: ${toolName}`);
// Return { allowed: false, reason: '...' } to block execution
return { allowed: true, args };
};Post-execution middleware — runs after any tool executes:
import type { ToolCallResult, PluginContext } from 'cortex/plugins';
export const postMiddleware = async (
toolName: string,
result: ToolCallResult,
ctx: PluginContext
) => {
ctx.logger.info(`Tool ${toolName} took ${result.durationMs}ms`);
// Can modify and return a different result
return result;
};To use middleware, declare middleware:pre and/or middleware:post in your manifest capabilities.
MCP (Model Context Protocol) plugins wrap external JSON-RPC servers.
{
"name": "my-mcp-plugin",
"version": "1.0.0",
"description": "An MCP server plugin",
"kind": "mcp",
"entryPoint": "https://my-mcp-server.example.com/rpc",
"runtime": "deno",
"capabilities": ["tools", "network:fetch"],
"tools": [
{
"name": "search",
"description": "Search the MCP server",
"params": [
{ "name": "query", "type": "string", "description": "Search query", "required": true }
]
}
]
}CortexPrism creates a synthetic tool named mcp_my-mcp-plugin that sends JSON-RPC 2.0 requests:
POST https://my-mcp-server.example.com/rpc
Content-Type: application/json
{ "jsonrpc": "2.0", "id": 1, "method": "search", "params": { "query": "..." } }
The MCP server must handle standard JSON-RPC 2.0 requests and return responses in the format:
{ "jsonrpc": "2.0", "id": 1, "result": { ... } }WASM plugins compile to .wasm and run in a dedicated Worker sandbox with synchronous host functions.
Any language that compiles to WASM is supported (C, Rust, Zig, Go, AssemblyScript).
{
"name": "my-wasm-plugin",
"version": "1.0.0",
"description": "A WebAssembly plugin",
"kind": "wasm",
"entryPoint": "./plugin.wasm",
"runtime": "wasm",
"capabilities": ["tools", "network:fetch"],
"tools": [
{
"name": "wasm_tool",
"description": "Tool implemented in WASM",
"params": [
{ "name": "input", "type": "string", "description": "Input value", "required": true }
]
}
]
}| Export | Signature | Purpose |
|---|---|---|
plugin_get_abi_version |
() → i32 |
Return supported ABI version (must match host — currently 1) |
plugin_get_capabilities |
(outPtr, outLenPtr) → i32 |
Write capabilities JSON (with param schemas) |
plugin_execute_tool |
(namePtr, nameLen, argsPtr, argsLen, outPtr, outLenPtr) → i32 |
Execute a tool |
memory |
WebAssembly.Memory |
Shared linear memory |
plugin_init |
() → void |
Optional — called once on load |
plugin_destroy |
() → void |
Optional — called on unload |
Include full parameter schemas so LLMs understand tool signatures:
{
"abi_version": 1,
"tools": [{
"name": "wasm_tool",
"description": "Tool implemented in WASM",
"params": [
{ "name": "input", "type": "string", "description": "Input", "required": true }
]
}]
}| Function | Purpose |
|---|---|
host_alloc(size) → ptr |
Allocate from host-managed heap |
host_log(ptr, len) |
Log to CortexPrism |
host_get_config(keyPtr, keyLen, outPtr, outLenPtr) → i32 |
Read CORTEX_PLUGIN_{NAME}_{KEY} or CORTEX_WASM_{KEY} env var |
host_set_state(keyPtr, keyLen, valPtr, valLen) |
Persist state (in-memory + SQLite) |
host_get_state(keyPtr, keyLen, outPtr, outLenPtr) → i32 |
Read persisted state |
host_http_request(methodPtr, methodLen, urlPtr, urlLen, bodyPtr, bodyLen, headersPtr, headersLen, outStatusPtr, outBodyPtr, outBodyLenPtr) → i32 |
Synchronous HTTP (30 s timeout, gated on network:fetch) |
host_get_abi_version() → i32 |
Returns host ABI version |
host_get_time_ms() → i64 |
Current time in ms |
host_random(outPtr, len) |
Cryptographically random bytes |
All strings are UTF-8 in linear memory. Return 0 for success, non-zero for error.
120 s execution timeout per tool. State persists across reloads.
See WASM Plugins wiki for the full ABI reference, memory layout diagram, SDK usage, and debugging guide.
-
Install locally:
cortex plugins install ./my-plugin/manifest.json
-
Enable it:
cortex plugins enable my-plugin -
Verify it's active:
cortex plugins list
-
Use the tool in a chat session or check the Web UI (
cortex serve). -
Check logs for errors:
cortex plugins list # shows status and error_message -
Inspect permissions:
cortex plugins permissions my-plugin
-
Update after making changes:
cortex plugins update my-plugin
cortex plugins list # overview
cortex plugins permissions my-plugin # permission detailsPlugin state and data are stored under ~/.cortex/data/plugins/<plugin-name>/:
~/.cortex/data/plugins/my-plugin/
├── esm/ # downloaded ESM source for remote installs
├── wasm/ # downloaded WASM binaries
├── data/ # plugin-private data directory
└── state.json # persisted ctx.state values
| Symptom | Likely Cause |
|---|---|
Status: error |
Check error_message via cortex plugins list. Common causes: missing exports, import errors, capability mismatch |
| Tool not available to agent | Plugin must be active and tool must be in the tools export |
| Permission denied at runtime | Tool's capabilities array must be a subset of the plugin's manifest capabilities |
| UI panel not showing | Declare ui:panel capability. Check htmlPath is relative to manifest and the file exists |
| Events not firing | Declare events:listener capability AND list event types in events manifest field |
Plugins can hook into the agent's execution pipeline at 12 named stages. This is more powerful than
the simple middleware:pre / middleware:post exports — hooks can abort execution, inject messages,
modify the LLM response, and more.
Declare middleware:pre or middleware:post in your manifest capabilities and export
middlewarePre / middlewarePost from your module. The loader automatically registers them as
pipeline hooks.
| Stage | When it runs |
|---|---|
pre-assess |
Before metacognitive assessment |
post-assess |
After assessment, before prompt build |
pre-reason |
Before the reasoning loop starts |
post-reason |
After the reasoning loop ends |
pre-tool |
Before each tool call (middleware:pre) |
post-tool |
After each tool call (middleware:post) |
pre-llm |
Before the LLM API call |
post-llm |
After the LLM API call |
pre-reflect |
Before reflection |
post-reflect |
After reflection |
pre-output |
Before the final response is sent |
post-output |
After the final response is sent |
The hook receives a read-only PipelineContext:
interface PipelineContext {
readonly stage: PipelineStage;
readonly sessionId: string;
readonly turnId: string;
readonly input?: string; // user message (pre-reason and earlier)
readonly assessment?: MetaAssessment;
readonly messages?: Message[]; // conversation history
readonly currentLLMResponse?: string;
readonly toolCall?: ToolCallRequest; // available in pre-tool / post-tool
readonly toolResult?: ToolCallResult; // available in post-tool
readonly reflection?: string;
readonly output?: string; // available in pre-output / post-output
readonly state: Readonly<AgentState>;
setState(updates: Partial<AgentState>): void;
}Return a HookResult from your hook. All fields are optional.
interface HookResult {
abort?: { reason: string; message: string }; // abort the pipeline turn
modifyInput?: string; // replace the user input
modifyLLMResponse?: string; // replace the raw LLM response text
modifyOutput?: string; // replace the final output
injectMessages?: Message[]; // append messages to conversation history
sideEffects?: SideEffect[]; // log | metric | store | notify
}This example blocks tool calls that match a denylist:
import type { PluginContext } from 'cortex/plugins';
export const middlewarePre = async (ctx: unknown) => {
const c = ctx as {
toolCall?: { name: string };
stage: string;
};
if (c.stage !== 'pre-tool' || !c.toolCall) return {};
const BLOCKED = ['shell_run', 'file_delete'];
if (BLOCKED.includes(c.toolCall.name)) {
return {
abort: {
reason: 'policy',
message: `Tool "${c.toolCall.name}" is blocked by the security plugin.`,
},
};
}
return {};
};This example logs every tool call duration to the plugin state:
export const middlewarePost = async (ctx: unknown) => {
const c = ctx as {
toolResult?: { toolName: string; durationMs: number; success: boolean };
stage: string;
};
if (c.stage !== 'post-tool' || !c.toolResult) return {};
console.log(
`[audit] ${c.toolResult.toolName} → ${c.toolResult.success ? 'ok' : 'fail'} (${c.toolResult.durationMs}ms)`
);
return {};
};Note: Hooks registered by
middlewarePrerun at stagepre-toolwithpriority: 50. Hooks registered bymiddlewarePostrun at stagepost-toolwithpriority: 50. Sync hooks time out after 5 seconds; async hooks after 15 seconds.
By default, trusted plugins run in-process with full declared permissions. untrusted and
signed plugins run in a Deno Worker sandbox with restricted permissions derived from their
declared capabilities.
The sandbox communicates with the host via a JSON-RPC protocol over postMessage. Tool calls
are proxied through the worker boundary.
| Trust Level | Source |
|---|---|
untrusted |
No verification or blocked/suspicious supply-chain |
signed |
Unverified supply-chain (no hash match) |
trusted |
Verified supply-chain check passes |
| Capability | Deno permission granted |
|---|---|
fs:read, fs:list |
read: true |
fs:write, fs:edit, fs:delete |
write: true |
shell:run |
run: true |
network:fetch, net:outbound |
net: true |
net:inbound |
net: true |
Sandboxed plugins must export a ready signal and respond to getTools and executeTool RPC
methods via postMessage:
// sandbox-entry.ts (worker)
import { tools } from './tools.ts';
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 emit a
{ type: 'ready' }message within 30 seconds or the worker is terminated and the plugin fails to load.
Plugins are namespaced using the @author/name convention. This prevents name collisions between
plugins from different authors.
@acme/weather-plugin
@cortex/built-in-search
Tool names within a namespaced plugin are accessed as @author/plugin-name/tool_name.
import { parsePluginName, formatPluginName, toolName } from 'cortex/plugins';
const parsed = parsePluginName('@acme/weather');
// { author: 'acme', name: 'weather', fullName: '@acme/weather' }
const full = formatPluginName('acme', 'weather');
// '@acme/weather'
const tool = toolName('@acme/weather', 'get_weather');
// '@acme/weather/get_weather'Unscoped plugin names (e.g. my-plugin) are automatically resolved as @unknown/my-plugin.
The author namespace is validated against a signing key to prevent impersonation.
The plugin loader reads these named exports from your mod.ts:
import type {
Tool,
PluginContext,
CliCommandDeclaration,
} from 'cortex/plugins';
// Tools registered in the agent tool registry
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 onLoad = async (ctx: PluginContext) => {};
export const onUnload = async (ctx: PluginContext) => {};
export const onInstall = async (ctx: PluginContext) => {};
export const onActivate = async (ctx: PluginContext) => {};
export const onDeactivate = 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) => ({} as HookResult);
export const middlewarePost = async (ctx: unknown) => ({} as HookResult);All exports are optional. You only need to export what your plugin uses.
When your panel HTML is served inside an iframe, the CortexPrism host injects a window.Cortex
global into the panel's JS context. This gives panel JS access to the host without any cross-origin
issues.
// Available as window.Cortex inside panel iframes
// Make authenticated REST API calls
const response = await window.Cortex.fetch('/api/sessions');
const data = await response.json();
// Make plugin-scoped API calls (resolves relative to /api/plugins/<name>/)
const config = await window.Cortex.fetch('config');
// Read a plugin config value
const apiKey = await window.Cortex.getConfig('apiKey');
// Write a plugin config value
await window.Cortex.setConfig('theme', 'dark');
// Listen for events emitted by the parent frame
window.Cortex.onEvent('session:start', (data) => {
console.log('Session started:', data);
});
// Emit an event to the parent frame
window.Cortex.emit('my-plugin:refresh', { timestamp: Date.now() });
// Show a host notification
window.Cortex.notify('Operation complete', 'info'); // 'info' | 'warn' | 'error'Under the hood, the panel communicates with the host via postMessage. Events from the panel to
the host use:
window.parent.postMessage({
type: 'cortex-event',
pluginName: 'my-plugin',
event: 'my-event',
data: { /* payload */ }
}, '*');Notifications from the panel to the host use:
window.parent.postMessage({
type: 'cortex-notification',
pluginName: 'my-plugin',
notification: { msg: 'Done!', type: 'info' }
}, '*');Panels can issue structured commands by emitting specific event types:
| Command type | Effect |
|---|---|
navigate |
Navigate the host UI to a route |
open-modal |
Open a modal with title and HTML content |
notification |
Show a notification at info/warn/error level |
config-get |
Read a config key |
config-set |
Write a config key |
query |
Execute an arbitrary query |
Administrators can grant or deny specific capabilities to plugins beyond what the manifest declares. Overrides are stored per-plugin in the database.
// Grant an extra capability at runtime (admin action via REST API)
// POST /api/plugins/:name/permissions
// { "permission_path": "fs:read", "action": "grant", "value": "" }
// Deny a declared capability
// POST /api/plugins/:name/permissions
// { "permission_path": "shell:run", "action": "deny", "value": "" }
// The effective permissions are computed as:
// declared - denied + grantedEffective permissions determine which Deno Worker permissions are granted in sandbox mode. A plugin
whose effective permissions do not include shell:run cannot spawn subprocesses even if its
manifest declares it.
Here is a minimal but complete plugin that adds a REST-connected tool, subscribes to events, and provides a UI panel:
my-weather-plugin/
├── manifest.json
├── mod.ts
└── ui/
└── panel.html
manifest.json:
{
"name": "my-weather-plugin",
"version": "1.0.0",
"description": "Fetches weather and shows a panel",
"kind": "esm",
"entryPoint": "./mod.ts",
"runtime": "deno",
"capabilities": ["tools", "ui:panel", "events:listener", "network:fetch"],
"events": ["session:start", "llm:post-call"],
"tools": [
{
"name": "get_weather",
"description": "Get weather for a city",
"params": [
{ "name": "city", "type": "string", "description": "City name", "required": true }
]
}
],
"ui": {
"panels": [
{ "id": "weather", "title": "Weather", "icon": "cloud", "htmlPath": "./ui/panel.html" }
],
"settings": [
{
"section": "API",
"fields": [
{ "key": "apiKey", "label": "Weather API Key", "type": "secret", "defaultValue": "" }
]
}
]
}
}mod.ts:
import type { Tool, PluginContext } from 'cortex/plugins';
import { globalEventBus } from 'cortex/plugins';
let callCount = 0;
export const onLoad = async (ctx: PluginContext) => {
ctx.logger.info('Weather plugin loaded');
globalEventBus.on('session:start', (event) => {
ctx.logger.info(`New session: ${(event as { sessionId: string }).sessionId}`);
});
globalEventBus.on('llm:post-call', (event) => {
const e = event as { tokensIn: number; tokensOut: number };
ctx.logger.debug(`LLM call: ${e.tokensIn}in / ${e.tokensOut}out`);
});
};
export const onUnload = async (ctx: PluginContext) => {
ctx.logger.info(`Weather plugin unloaded. Total calls: ${callCount}`);
};
const weatherTool: Tool = {
definition: {
name: 'get_weather',
description: 'Get current weather for a city',
params: [
{ name: 'city', type: 'string', description: 'City name', required: true },
],
capabilities: ['network:fetch'],
},
execute: async (args, ctx) => {
const start = Date.now();
callCount++;
const city = args.city as string;
if (!city) {
return {
toolName: 'get_weather',
success: false,
output: '',
error: 'city parameter is required',
durationMs: 0,
};
}
const apiKey = await ctx.config.get<string>('apiKey');
if (!apiKey) {
return {
toolName: 'get_weather',
success: false,
output: '',
error: 'No API key configured. Set it in plugin settings.',
durationMs: Date.now() - start,
};
}
try {
const res = await fetch(
`https://api.weather.example/current?city=${encodeURIComponent(city)}&key=${apiKey}`
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.text();
return { toolName: 'get_weather', success: true, output: data, durationMs: Date.now() - start };
} catch (err) {
return {
toolName: 'get_weather',
success: false,
output: '',
error: `Failed to fetch weather: ${(err as Error).message}`,
durationMs: Date.now() - start,
};
}
},
};
export const tools = [weatherTool];ui/panel.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Weather Panel</title>
<style>
body { font-family: system-ui; padding: 1rem; background: #0a0a0f; color: #e2e2ea; }
button { background: #6366f1; color: white; border: none; padding: 0.5rem 1rem; cursor: pointer; border-radius: 4px; }
#output { margin-top: 1rem; white-space: pre-wrap; }
</style>
</head>
<body>
<input id="city" type="text" placeholder="City name" />
<button onclick="getWeather()">Get Weather</button>
<div id="output"></div>
<script>
async function getWeather() {
const city = document.getElementById('city').value;
const res = await window.Cortex.fetch(`/api/tools/get_weather`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ city }),
});
const data = await res.json();
document.getElementById('output').textContent = JSON.stringify(data, null, 2);
}
</script>
</body>
</html>- Best Practices — Design principles, per-kind guidance, testing, and what to avoid
- Publishing — Submit your plugin to the CortexPrism Marketplace via web or API
- Submission Standards — Repository structure, versioning rules, AI disclosure, and the full submission checklist
- Manifest Reference — Complete manifest schema and PluginContext/PluginEvents APIs