Codumentor logo Codumentor

Plugin System Overview

Codumentor's plugin system lets you extend the agent with custom tools, UI components, lifecycle hooks, and HTTP endpoints.

See also: Architecture Overview for how plugins fit into the broader system.

Two-Part Architecture

ComponentLanguagePurpose
Backend PluginPythonIntercepts agent lifecycle hooks, emits events, exposes API routes
UI PluginTypeScriptRenders custom elements in the web interface

Most plugins need both parts. The backend handles logic and emits events; the UI renders those events.

Plugin Instances & State

A backend plugin is instantiated in more than one place, and the instances
do not share in-memory state:

InstanceCreatedLives as long asReceives
Server-levelOnce at API startupThe processonServerReady; owns the plugin's API routers (/ui/plugins/<id>/…)
Per-conversation (agent-level)Once per AgentThat conversation's sessionThe per-turn lifecycle hooks (onInputReceived, onContextReady, onResponsePersist, …)

So a counter your onResponsePersist hook increments on the agent-level
instance is invisible to the API route served by the server-level instance
— they are different Python objects with different attributes. This is by
design: the server instance answers HTTP regardless of which conversation is
active, while each conversation gets its own isolated hook instance.

Crucially, all of these instances live in the same process (the API/TUI
process — see Concurrency). So sharing state
between them is a same-process problem, not a persistence problem. Pick the
narrowest mechanism that fits:

1. Across instances of your plugin → a singleton keyed by config

This is the case above (server instance ↔ agent hook instances). **Do not reach
for a JSON file + lock** — the instances share an address space, so a file only
adds I/O, lock contention, and serialization for data that never needs to leave
the process. Use the shared_state SDK helper: it returns one object per
(config, key), created on first access and reused by every instance
afterwards. (It generalizes the built-in get_speech_service /
get_job_queue_registry singletons; the same Config object is threaded to
every plugin instance, which is what makes the state shared.)

import threading
from codumentor.plugins.shared_state import shared_state

class _Stats:
    def __init__(self) -> None:
        self.lock = threading.Lock()
        self.inputs = 0
        self.responses = 0

def get_stats(config):
    return shared_state(config, "request_stats", _Stats)
# In the onResponsePersist hook (agent-level instance):
stats = get_stats(self.config)
with stats.lock:
    stats.responses += 1

# In the /stats API route (server-level instance) — same object:
stats = get_stats(self.config)
return {"inputs": stats.inputs, "responses": stats.responses}

shared_state serializes creation (so factory runs at most once per pair),
but the returned object owns its own thread-safety — hence the internal
threading.Lock guarding the counters against concurrent turns. No file, no
fsync, no flock.

2. Durable or cross-process state → a SQLite store

A file is only justified when the data must outlive the process or be shared by
separate processes (e.g. several API instances pointed at one data dir). In
that case use a SQLite store under your plugin's data dir with WAL — the same
mechanism jobs/goals/memory use — not an ad-hoc JSON file with flock, which
has no atomicity or crash-safety guarantees. See
Concurrency for the WAL + atomic-claim pattern.

3. State scoped to a single conversation → the scratchpad

Shared between the main agent, its subagents, and all hook handlers in that one
session: use ctx["scratchpad"] — see Conversation Scratchpad
below. In-memory, cleared on session eviction, so not a substitute for (1) or (2).

What Plugins Can Do

Plugin Bus & Lifecycle

The Plugin Bus dispatches events at each stage of agent execution. Plugins subscribe to hooks during register() and receive callbacks with (agent, ctx, payload).

Execution Flow

  1. onInputReceived — Raw user input arrives
  2. onContextReady — Conversation context loaded
  3. onPromptAssemble — Prompt built, tools registered
  4. onLLMCallStartonLLMStreamonLLMCallEnd — LLM interaction
  5. onToolInvokeStartonToolExecutingonToolInvokeEnd — Tool execution
  6. onResponsePersistonBeforeSendResponse — Response finalization

Control hooks (onAfterInput, onAfterLLM, onAfterTool) run after each stage and can short-circuit the pipeline.

Context Object

All handlers receive a shared context:

ctx = {
    "run_id": str,                  # Unique ID for this agent run
    "session_id": str,              # Conversation session ID
    "user_id": str,                 # User identifier
    "step": int,                    # Current step in agent loop
    "kv": dict,                     # Free-form storage for plugins
    "scratchpad": ConversationScratchpad,  # Cross-agent shared storage
}

Store plugin data in ctx["kv"].setdefault("artifacts", {})[plugin_name].

Conversation Scratchpad

ctx["scratchpad"] is a thread-safe in-memory key-value store scoped to a single conversation. The same instance is shared by the main agent, all subagents, and every plugin handler in that session, so it's the right place to publish data that downstream hooks or other plugins need to read — for example, file reads tracked by the permalink plugin, or modified-file metadata tracked by file_links. By convention, plugins namespace their keys (e.g. local_files_modified) and use the typed helpers set / get / update (dict merge) / append (list append). The scratchpad is cleared automatically when the session is evicted.

Note: this is unrelated to the workspace_isolation plugin, which manages on-disk sandbox directories. If you need a filesystem sandbox, see workspace_isolation; for shared in-process state, use the scratchpad.

async def on_tool_invoke_end(self, agent, ctx, payload):
    scratchpad = ctx.get("scratchpad")
    if scratchpad is None:
        return None
    scratchpad.update("my_plugin.files_seen", {payload["path"]: True})

Shared Repo Context

Plugins that need the active branch for a repository (e.g. after a user selects a branch in the UI) can read it from the well-known repo_context key in ctx["kv"]. Helper functions in codumentor.plugins.repo_context provide typed access:

from codumentor.plugins.repo_context import (
    set_repo_context,       # Publisher — write context
    get_active_branch,      # Read active branch for one repo
    get_all_active_branches,# Read all active branches as {repo: branch}
)

# Publishing (e.g. in a branch-switching plugin):
set_repo_context(ctx, "my-repo", branch="feature-x")

# Consuming (e.g. in an output filter):
branch = get_active_branch(ctx, "my-repo")         # "feature-x" or None
all_branches = get_all_active_branches(ctx)         # {"my-repo": "feature-x"}

Key design points:

Shutdown & Cleanup

When the application exits, PluginHost.shutdown() runs while the event loop is
still active, giving plugins a chance to release resources cleanly. This is
especially important on Windows, where subprocess transports that outlive the
event loop cause RuntimeError: Event loop is closed.

Teardown happens in two phases:

  1. Instance cleanupcleanup() is called on every registered plugin instance. Use this to close connections, cancel background tasks, and release any resources the instance owns.
  1. Class-level shutdownshutdown_all() is called once per distinct plugin class. Use this when multiple instances share class-level resources (e.g. a shared subprocess pool or connection registry).
class MyPlugin:
    _shared_pool = {}           # Class-level shared resource

    async def cleanup(self):
        """Release instance-owned resources."""
        await self._connection.close()

    @classmethod
    async def shutdown_all(cls):
        """Tear down the shared pool (called once, after all instance cleanup)."""
        for key, conn in cls._shared_pool.items():
            await conn.close()
        cls._shared_pool.clear()

Both methods are optional. Errors in either are logged and swallowed so one
plugin cannot prevent others from shutting down.

Source Context

When a conversation is linked to a non-default source (e.g., a log collection), the agent runs with a SourceContext that controls its working directory, available tools, and prompt variables. Plugin handlers receive the agent's context via ctx, and the agent instance carries agent._source_ctx when a source context is active.

For details on the source abstraction and available source types, see Source Abstraction.

Next Steps

GuideDescription
Quick StartBuild a "hello world" plugin in 15 minutes
Backend HooksComplete hook reference and return values
UI ComponentsTypeScript components, SDK, widgets
User GatesPer-user blocking modals (EULA, privacy updates, forced password change)
API RoutesHTTP endpoints and authentication
Best PracticesError handling, performance, testing
Plugin CatalogExisting plugins for reference