Skip to main content
Plugins extend Comis by hooking into lifecycle events, registering custom tools, adding HTTP routes, and defining config schemas. Every plugin implements the PluginPort interface and uses the PluginRegistryApi to register its capabilities. The hook system runs at every lifecycle point — 5 modifying hooks (can change behavior) and 8 void hooks (observe only).

PluginPort Interface

A plugin is any object that satisfies the PluginPort interface. The registry calls register() once during bootstrap, passing a PluginRegistryApi facade scoped to the plugin’s ID.
import type { PluginPort, PluginRegistryApi } from "@comis/core";
import type { Result } from "@comis/shared";

export interface PluginPort {
  readonly id: string;
  readonly name: string;
  readonly version?: string;
  register(registry: PluginRegistryApi): Result<void, Error>;
  activate?(): Promise<Result<void, Error>>;
  deactivate?(): Promise<Result<void, Error>>;
}
  • id — Unique identifier for the plugin (e.g., "audit-logger", "webhook-forwarder")
  • name — Human-readable name displayed in diagnostics
  • register() — Called once during plugin loading. Use the registry parameter to register hooks, tools, routes, and config schemas. Must be synchronous.
  • activate() — Optional async initialization (connect to external services, warm caches)
  • deactivate() — Optional async cleanup (close connections, flush buffers)

Registration Methods

The PluginRegistryApi exposes four methods for extending Comis. Each method is called within register() and takes effect immediately.

registerHook

registry.registerHook(hookName, handler, options?);
Subscribe to a lifecycle hook. The hookName must be a valid hook name from the HookName type. Options include priority (number, default 0, higher runs first, range -100 to 100).

registerTool

import type { PluginToolDefinition } from "@comis/core";

registry.registerTool({
  name: "my_custom_tool",
  description: "Does something useful",
  parameters: {
    type: "object",
    properties: {
      query: { type: "string", description: "Search query" },
    },
    required: ["query"],
  },
  execute: async (params) => {
    // Perform the tool action
    return { result: "success" };
  },
});
Register a custom tool that agents can invoke. The tool definition includes a name, description, JSON Schema parameters object, and an async execute function. Plugin-provided tools appear alongside built-in tools and skill-provided tools — agents see them identically.

registerHttpRoute

import type { PluginHttpRoute } from "@comis/core";

registry.registerHttpRoute({
  method: "GET",
  path: "/api/my-plugin/status",
  handler: async (req) => {
    return { status: "ok", timestamp: Date.now() };
  },
});
Add a custom HTTP route to the gateway. The route definition includes the HTTP method (GET, POST, PUT, DELETE, PATCH), path (must start with /), and an async handler function.

registerConfigSchema

import { z } from "zod";

registry.registerConfigSchema("myPlugin", z.object({
  apiKey: z.string(),
  maxRetries: z.number().int().positive().default(3),
  enabled: z.boolean().default(true),
}));
Register a plugin-specific configuration section with a Zod schema. The section name becomes a key in the config YAML file, and the schema validates values at load time.

Lifecycle Hooks

The hook system is the primary extension mechanism for customizing Comis behavior. Every hook is organized into one of six domains: Agent, Tool, Compaction, Delivery, Session, and Gateway.
HookTypeWhen It RunsCan Modify
before_agent_startModifyingBefore agent processes a messagesystemPrompt, prependContext
before_tool_callModifyingBefore a tool executesparams, block (boolean), blockReason
tool_result_persistModifying (SYNC)When tool result is saved to session transcriptresult
before_compactionModifyingBefore session compaction runscancel (boolean), cancelReason
before_deliveryModifyingBefore a message is delivered to a channeltext, cancel (boolean), cancelReason, metadata
agent_endVoidAfter agent finishes processing
after_tool_callVoidAfter a tool completes
after_compactionVoidAfter session compaction completes
after_deliveryVoidAfter a message is delivered to a channel
session_startVoidWhen a new session begins
session_endVoidWhen a session ends
gateway_startVoidWhen the HTTP gateway starts
gateway_stopVoidWhen the HTTP gateway stops

Modifying Hooks

Modifying hooks run sequentially in priority order (higher priority first). Each handler can return an object with fields to modify. Results are merged using last-writer-wins for each field — if two hooks both set systemPrompt, the higher-priority hook’s value is used. Return values are validated against Zod schemas before merging. Invalid returns are logged and ignored, protecting the system from malformed hook output.

Void Hooks

Void hooks run in parallel (fire-and-forget). Handlers observe events but cannot change them. Errors in void hooks are caught and logged without affecting the system.

Priority Ordering

Higher priority numbers run first (range -100 to 100, default 0). For example, a hook with priority 10 runs before a hook with priority 0. This applies to modifying hooks where execution order determines which values take precedence.
tool_result_persist handlers MUST be synchronous. This hook runs in a synchronous code path (session transcript append). If you return a Promise, it will cause undefined behavior. All other hooks can be async.

Hook Details

Fires before an agent processes a message. Use this to inject context, modify the system prompt, or add preamble text.Event payload:
  • systemPrompt (string) — The current system prompt
  • messages (unknown[]) — Message history
Context:
  • agentId (string) — The agent processing the message
  • sessionKey (SessionKey) — Current session identifier
  • workspaceDir (string) — Workspace directory path
  • isFirstMessageInSession (boolean) — Whether this is the first user message
Return (optional):
  • systemPrompt (string) — Override the system prompt
  • prependContext (string) — Text prepended to the agent’s context
import type { PluginPort, PluginRegistryApi } from "@comis/core";
import { ok, type Result } from "@comis/shared";

registry.registerHook("before_agent_start", (event, ctx) => {
  if (ctx.isFirstMessageInSession) {
    return { prependContext: "Welcome! This is a new session." };
  }
});
Fires before a tool executes. Use this to modify parameters, block dangerous tools, or enforce security policies.Event payload:
  • toolName (string) — Name of the tool being called
  • params (Record<string, unknown>) — Tool parameters
Context:
  • agentId (string) — The agent calling the tool
  • sessionKey (SessionKey) — Current session identifier
Return (optional):
  • params (Record<string, unknown>) — Modified parameters
  • block (boolean) — Set to true to prevent the tool from executing
  • blockReason (string) — Reason for blocking (logged and returned to agent)
import type { PluginPort, PluginRegistryApi } from "@comis/core";
import { ok, type Result } from "@comis/shared";

registry.registerHook("before_tool_call", (event, ctx) => {
  if (event.toolName === "dangerous_tool") {
    return { block: true, blockReason: "Blocked by security policy" };
  }
  // Return nothing to allow the call unchanged
}, { priority: 10 });
Fires when a tool result is saved to the session transcript. Use this to redact sensitive data or transform tool output before it is persisted.Event payload:
  • toolName (string) — Name of the tool that executed
  • result (string) — The tool’s result text
Context:
  • agentId (string) — The agent that called the tool
  • sessionKey (SessionKey) — Current session identifier
Return (optional):
  • result (string) — Modified result text to persist
registry.registerHook("tool_result_persist", (event, ctx) => {
  // Redact API keys from persisted results (MUST be synchronous)
  const redacted = event.result.replace(/sk-[a-zA-Z0-9]{32,}/g, "[REDACTED]");
  return { result: redacted };
});
Fires before the context engine’s LLM compaction layer runs (the last-resort summarization step). Use this to cancel compaction for specific sessions or log compaction decisions. See Compaction for how compaction fits into the 10-layer context engine.Event payload:
  • sessionKey (SessionKey) — Session being compacted
  • messageCount (number) — Current message count
  • estimatedTokens (number) — Estimated token count (optional)
Context:
  • agentId (string) — The agent whose session is being compacted
Return (optional):
  • cancel (boolean) — Set to true to prevent compaction
  • cancelReason (string) — Reason for cancellation
registry.registerHook("before_compaction", (event, ctx) => {
  if (event.messageCount < 50) {
    return { cancel: true, cancelReason: "Too few messages to compact" };
  }
});
Fires after an agent finishes processing a message. Use this for logging, metrics collection, or post-processing.Event payload:
  • durationMs (number) — Total processing time
  • tokenUsage (object) — Token counts: prompt, completion, total (optional)
  • success (boolean) — Whether processing succeeded
  • error (string) — Error message if failed (optional)
Context:
  • agentId (string) — The agent that processed the message
  • sessionKey (SessionKey) — Current session identifier
registry.registerHook("agent_end", (event, ctx) => {
  console.log(`Agent ${ctx.agentId}: ${event.durationMs}ms, ${event.tokenUsage?.total ?? 0} tokens`);
});
Fires after a tool completes execution. Use this for audit logging, metrics, or triggering side effects.Event payload:
  • toolName (string) — Name of the tool that executed
  • params (Record<string, unknown>) — Parameters that were passed
  • result (unknown) — The tool’s return value
  • durationMs (number) — Execution time
  • success (boolean) — Whether execution succeeded
Context:
  • agentId (string) — The agent that called the tool
  • sessionKey (SessionKey) — Current session identifier
registry.registerHook("after_tool_call", (event, ctx) => {
  console.log(`[audit] ${event.toolName} by ${ctx.agentId} (${event.durationMs}ms)`);
});
Fires after the context engine’s LLM compaction layer completes. Use this for logging compaction results or triggering downstream actions like notifying monitoring systems.Event payload:
  • sessionKey (SessionKey) — Session that was compacted
  • removedCount (number) — Messages removed
  • retainedCount (number) — Messages retained
  • durationMs (number) — Compaction duration
Context:
  • agentId (string) — The agent whose session was compacted
registry.registerHook("after_compaction", (event, ctx) => {
  console.log(`Compacted: removed ${event.removedCount}, retained ${event.retainedCount}`);
});
Fires before a message is delivered to a channel. Use this to modify the outgoing text, cancel delivery, or attach metadata for downstream processing. See Delivery for how delivery hooks fit into the message pipeline.Event payload:
  • text (string) — The message text about to be delivered
  • channelType (string) — Target channel type (e.g., “telegram”, “discord”)
  • channelId (string) — Target channel identifier
  • options (Record<string, unknown>) — Platform-specific delivery options
  • origin (string) — Where the message originated (e.g., “agent”, “system”)
Context:
  • sessionKey (string) — Current session identifier (optional)
  • agentId (string) — The agent sending the message (optional)
  • traceId (string) — Request trace identifier (optional)
Return (optional):
  • text (string) — Modified message text
  • cancel (boolean) — Set to true to prevent delivery
  • cancelReason (string) — Reason for cancellation (logged)
  • metadata (Record<string, unknown>) — Arbitrary metadata passed to the delivery layer
registry.registerHook("before_delivery", (event, ctx) => {
  // Add a footer to all outgoing messages
  return { text: event.text + "\n\n_Sent via Comis_" };
});
Fires after a message has been delivered to a channel. Use this for delivery tracking, analytics, or triggering follow-up actions.Event payload:
  • text (string) — The message text that was delivered
  • channelType (string) — Channel type the message was delivered to
  • channelId (string) — Channel identifier
  • result (unknown) — Platform-specific delivery result
  • durationMs (number) — Delivery duration in milliseconds
  • origin (string) — Where the message originated
Context:
  • sessionKey (string) — Current session identifier (optional)
  • agentId (string) — The agent that sent the message (optional)
  • traceId (string) — Request trace identifier (optional)
registry.registerHook("after_delivery", (event, ctx) => {
  console.log(`Delivered to ${event.channelType}:${event.channelId} in ${event.durationMs}ms`);
});
Fires when a new session begins. Use this for session tracking, initializing per-session state, or logging.Event payload:
  • sessionKey (SessionKey) — The new session identifier
  • isNew (boolean) — Whether this is a brand-new session or a resumed one
Context:
  • agentId (string) — The agent for this session (optional)
registry.registerHook("session_start", (event, ctx) => {
  if (event.isNew) {
    console.log(`New session started: ${event.sessionKey.channelId}`);
  }
});
Fires when a session ends. Use this for cleanup, final logging, or triggering follow-up actions.Event payload:
  • sessionKey (SessionKey) — The session that ended
  • reason (string) — Why the session ended
  • durationMs (number) — Total session duration (optional)
Context:
  • agentId (string) — The agent for this session (optional)
registry.registerHook("session_end", (event, ctx) => {
  console.log(`Session ended (${event.reason}): ${event.durationMs ?? 0}ms`);
});
Fires when the HTTP gateway starts. Use this for initialization that depends on the gateway being ready.Event payload:
  • port (number) — The port the gateway is listening on
  • host (string) — The host address
  • tls (boolean) — Whether TLS is enabled
registry.registerHook("gateway_start", (event) => {
  console.log(`Gateway ready on ${event.host}:${event.port} (TLS: ${event.tls})`);
});
Fires when the HTTP gateway stops. Use this for cleanup or graceful shutdown coordination.Event payload:
  • reason (string) — Why the gateway is stopping
registry.registerHook("gateway_stop", (event) => {
  console.log(`Gateway stopping: ${event.reason}`);
});

Complete Plugin Example

This audit logger plugin demonstrates multiple registration types: lifecycle hooks (both observing and modifying), a custom HTTP route, and priority ordering. This is a non-channel plugin — for channel adapter plugins, see Custom Adapters.
import type { PluginPort, PluginRegistryApi } from "@comis/core";
import { ok, type Result } from "@comis/shared";

interface AuditEntry {
  timestamp: number;
  agentId: string;
  toolName: string;
  durationMs: number;
}

export const auditPlugin: PluginPort = {
  id: "audit-logger",
  name: "Audit Logger Plugin",
  version: "1.0.3",

  register(registry: PluginRegistryApi): Result<void, Error> {
    const recentLogs: AuditEntry[] = [];

    // 1. Observe all tool calls (void hook, parallel)
    registry.registerHook("after_tool_call", (event, ctx) => {
      recentLogs.push({
        timestamp: Date.now(),
        agentId: ctx.agentId,
        toolName: event.toolName,
        durationMs: event.durationMs,
      });
      // Keep only the last 1000 entries
      if (recentLogs.length > 1000) recentLogs.shift();
    });

    // 2. Block specific tools (modifying hook, sequential)
    registry.registerHook("before_tool_call", (event) => {
      if (event.toolName === "shell_exec") {
        return { block: true, blockReason: "shell_exec blocked by audit policy" };
      }
    }, { priority: 100 }); // High priority: run before other hooks

    // 3. Inject context before agent starts
    registry.registerHook("before_agent_start", (event) => {
      return { prependContext: "AUDIT MODE: All tool calls are logged." };
    });

    // 4. Register an HTTP route for audit data
    registry.registerHttpRoute({
      method: "GET",
      path: "/api/audit/recent",
      handler: async (req) => {
        return { logs: recentLogs.slice(-50) };
      },
    });

    return ok(undefined);
  },
};
This plugin demonstrates hooks (observe + modify), a custom HTTP route, and priority ordering. For channel adapter plugins, see Custom Adapters.

PluginRegistry Lifecycle

The PluginRegistry manages plugin state through the application lifecycle:
  • register(plugin) — Adds the plugin to the registry and calls plugin.register(api) with a scoped facade. Returns err() if the plugin ID is empty or already registered.
  • activateAll() — Calls activate() on all plugins that define it (after all plugins are registered). Collects and reports activation errors.
  • deactivateAll() — Calls deactivate() on all plugins during graceful shutdown. Runs in registration order.
  • getPlugin(id) / getPlugins() — Query registered plugins by ID or get all plugins.
  • unregister(id) — Remove a plugin and all its registered hooks.
The registry is created via createPluginRegistry() and wired in the composition root (bootstrap.ts). Plugins are registered during the bootstrap phase before the daemon starts accepting connections.

Custom Adapters

Channel adapter plugins (ChannelPluginPort)

Event Bus

Events that trigger hooks

Custom Skills

registerTool for agent tools

Architecture

PluginPort in the port system